diff --git a/src/app/(private)/[clubId]/(main)/mypage/activity/loading.tsx b/src/app/(private)/[clubId]/(main)/mypage/activity/loading.tsx new file mode 100644 index 000000000..2e92c3513 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/activity/loading.tsx @@ -0,0 +1,5 @@ +import { MyPageActivitySkeleton } from '@/components/mypage/skeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/activity/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/activity/page.tsx new file mode 100644 index 000000000..ae3f7836f --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/activity/page.tsx @@ -0,0 +1,7 @@ +import { MyPageActivityContent } from '@/components/mypage/MyPageActivityContent'; + +export const dynamic = 'force-dynamic'; + +export default function MyPageActivityPage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/layout.tsx b/src/app/(private)/[clubId]/(main)/mypage/layout.tsx new file mode 100644 index 000000000..2abe250e7 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/layout.tsx @@ -0,0 +1,16 @@ +import { MyPageNav } from '@/components/mypage/MyPageNav'; + +interface MyPageLayoutProps { + children: React.ReactNode; +} + +export default function MyPageLayout({ children }: MyPageLayoutProps) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/posts/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/posts/page.tsx new file mode 100644 index 000000000..1b7c27817 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/posts/page.tsx @@ -0,0 +1,7 @@ +import { MyPagePostsContent } from '@/components/mypage/MyPagePostsContent'; + +export const dynamic = 'force-dynamic'; + +export default function MyPagePostsPage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/profiles/[profileId]/edit/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/profiles/[profileId]/edit/page.tsx new file mode 100644 index 000000000..e7fd05abd --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/profiles/[profileId]/edit/page.tsx @@ -0,0 +1,7 @@ +import { EditProfilePageContent } from '@/components/mypage/EditProfilePageContent'; + +export const dynamic = 'force-dynamic'; + +export default function EditProfilePage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/profiles/add/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/profiles/add/page.tsx new file mode 100644 index 000000000..43a87508b --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/profiles/add/page.tsx @@ -0,0 +1,7 @@ +import { AddProfilePageContent } from '@/components/mypage/AddProfilePageContent'; + +export const dynamic = 'force-dynamic'; + +export default function AddProfilePage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/profiles/loading.tsx b/src/app/(private)/[clubId]/(main)/mypage/profiles/loading.tsx new file mode 100644 index 000000000..6c7de46f7 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/profiles/loading.tsx @@ -0,0 +1,5 @@ +import { ProfileManagementSkeleton } from '@/components/mypage/skeleton/ProfileManagementSkeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/profiles/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/profiles/page.tsx new file mode 100644 index 000000000..3f9da1dbc --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/profiles/page.tsx @@ -0,0 +1,7 @@ +import { ProfileManagementContent } from '@/components/mypage'; + +export const dynamic = 'force-dynamic'; + +export default function ProfilesPage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/sessions/loading.tsx b/src/app/(private)/[clubId]/(main)/mypage/sessions/loading.tsx new file mode 100644 index 000000000..0f9a11a9c --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/sessions/loading.tsx @@ -0,0 +1,5 @@ +import { MyPageSessionsSkeleton } from '@/components/mypage/skeleton/MyPageSessionsSkeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/sessions/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/sessions/page.tsx new file mode 100644 index 000000000..129752c23 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/sessions/page.tsx @@ -0,0 +1,25 @@ +import { MyPageSessionsContent } from '@/components/mypage/MyPageSessionsContent'; +import { attendanceServerApi } from '@/lib/apis/attendance.server'; +import type { AttendanceSummary } from '@/types/attendance'; + +interface MyPageSessionsPageProps { + params: Promise<{ clubId: string }>; +} + +export const dynamic = 'force-dynamic'; + +export default async function MyPageSessionsPage({ params }: MyPageSessionsPageProps) { + const { clubId } = await params; + + let summary: AttendanceSummary | undefined; + let errorMessage: string | undefined; + + try { + const response = await attendanceServerApi.getDetail(clubId); + summary = response.data; + } catch { + errorMessage = '출석 기록을 불러오지 못했습니다.'; + } + + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/settings/loading.tsx b/src/app/(private)/[clubId]/(main)/mypage/settings/loading.tsx new file mode 100644 index 000000000..546d5a5cd --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/settings/loading.tsx @@ -0,0 +1,5 @@ +import { MyPageSettingsSkeleton } from '@/components/mypage/skeleton'; + +export default function Loading() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/settings/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/settings/page.tsx new file mode 100644 index 000000000..f3851245c --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/settings/page.tsx @@ -0,0 +1,7 @@ +import { MyPageSettingsContent } from '@/components/mypage/MyPageSettingsContent'; + +export const dynamic = 'force-dynamic'; + +export default function MyPageSettingsPage() { + return ; +} diff --git a/src/app/(private)/[clubId]/(main)/mypage/settings/theme/page.tsx b/src/app/(private)/[clubId]/(main)/mypage/settings/theme/page.tsx new file mode 100644 index 000000000..91725e0c2 --- /dev/null +++ b/src/app/(private)/[clubId]/(main)/mypage/settings/theme/page.tsx @@ -0,0 +1,7 @@ +import { ThemeModePageContent } from '@/components/mypage/ThemeModePageContent'; + +export const dynamic = 'force-dynamic'; + +export default function ThemeModePage() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 951c52150..9edcaa551 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,6 +56,10 @@ --pink-700: #8e448a; --pink-900: #280526; + --opacity-60: #ffffff99; + --opacity-100: #00c8aa1f; + --opacity-200: #00c8aa4d; + --background: var(--neutral-200); --background-2: var(--neutral-300); --foreground: var(--text-normal); @@ -66,8 +70,9 @@ --text-alternative: var(--neutral-600); --text-disabled: var(--neutral-500); --text-inverse: var(--neutral-0); - --container-code: var(--neutral-400); + --text-brand: var(--primary-500); + --container-code: var(--neutral-400); --code-keyword: #d73a49; --code-string: #032f62; --code-comment: #6a737d; @@ -85,11 +90,15 @@ --container-secondary: var(--secondary-500); --container-secondary-interaction: var(--secondary-700); --container-secondary-alternative: var(--secondary-100); + --container-opacity: var(--opacity-60); --button-neutral: var(--neutral-400); --button-neutral-interaction: var(--neutral-500); --button-primary: var(--primary-500); --button-primary-interaction: var(--primary-600); + --button-primary-subtle: var(--opacity-100); + --button-primary-subtle-interaction: var(--opacity-200); + --button-disabled: var(--neutral-400); --icon-normal: var(--neutral-800); --icon-strong: var(--neutral-900); @@ -279,6 +288,15 @@ --pink-700: #f3a7f0; --pink-900: #faddf9; + --opacity-60: #1e202199; + --opacity-100: #355d5a80; + --opacity-200: #00c8aa66; + + --button-primary-subtle: var(--opacity-100); + --button-primary-subtle-interaction: var(--opacity-200); + + --container-opacity: var(--opacity-60); + --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); @@ -338,6 +356,11 @@ --color-button-neutral-interaction: var(--button-neutral-interaction); --color-button-primary: var(--button-primary); --color-button-primary-interaction: var(--button-primary-interaction); + --color-button-primary-subtle: var(--button-primary-subtle); + --color-button-primary-subtle-interaction: var(--button-primary-subtle-interaction); + --color-button-disabled: var(--button-disabled); + + --color-container-opacity: var(--container-opacity); --color-icon-normal: var(--icon-normal); --color-icon-strong: var(--icon-strong); diff --git a/src/assets/icons/add_round.svg b/src/assets/icons/add_round.svg new file mode 100644 index 000000000..8fbebb658 --- /dev/null +++ b/src/assets/icons/add_round.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/calendar.svg b/src/assets/icons/calendar.svg new file mode 100644 index 000000000..d06bdd63b --- /dev/null +++ b/src/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index a375c37c8..9972adf6d 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -64,3 +64,8 @@ export { default as RushIcon } from './rush.svg'; export { default as QuestionCircleIcon } from './question_circle.svg'; export { default as DotIcon } from './dot.svg'; export { default as EmptyListIcon } from './empty_list.svg'; +export { default as PhoneIcon } from './phone.svg'; +export { default as MailIcon } from './mail.svg'; +export { default as SettingIcon } from './setting.svg'; +export { default as AddRoundIcon } from './add_round.svg'; +export { default as CalendarIcon } from './calendar.svg'; diff --git a/src/assets/icons/mail.svg b/src/assets/icons/mail.svg new file mode 100644 index 000000000..3b316f994 --- /dev/null +++ b/src/assets/icons/mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/phone.svg b/src/assets/icons/phone.svg new file mode 100644 index 000000000..9f2e372a1 --- /dev/null +++ b/src/assets/icons/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/setting.svg b/src/assets/icons/setting.svg new file mode 100644 index 000000000..2031b0e13 --- /dev/null +++ b/src/assets/icons/setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/image/theme/theme_auto.png b/src/assets/image/theme/theme_auto.png new file mode 100644 index 000000000..7cbbed6f8 Binary files /dev/null and b/src/assets/image/theme/theme_auto.png differ diff --git a/src/assets/image/theme/theme_dark.png b/src/assets/image/theme/theme_dark.png new file mode 100644 index 000000000..2aa5ebf95 Binary files /dev/null and b/src/assets/image/theme/theme_dark.png differ diff --git a/src/assets/image/theme/theme_light.png b/src/assets/image/theme/theme_light.png new file mode 100644 index 000000000..b537f549d Binary files /dev/null and b/src/assets/image/theme/theme_light.png differ diff --git a/src/components/auth/hub/FormFieldWrapper.tsx b/src/components/auth/hub/FormFieldWrapper.tsx index 38d601434..c0969ddc5 100644 --- a/src/components/auth/hub/FormFieldWrapper.tsx +++ b/src/components/auth/hub/FormFieldWrapper.tsx @@ -8,7 +8,7 @@ function FieldError({ message }: { message?: string }) { } interface FormFieldWrapperProps { - label: string; + label?: string; error?: string; children: React.ReactNode; } diff --git a/src/components/board/BoardContent.tsx b/src/components/board/BoardContent.tsx index 99b1830f6..9bee2b417 100644 --- a/src/components/board/BoardContent.tsx +++ b/src/components/board/BoardContent.tsx @@ -24,9 +24,15 @@ function toDisplayImages(files: FileItem[]) { interface BoardContentProps { boardId: number | null; + onlyCurrentUser?: boolean; + emptyMessage?: string; } -function BoardContent({ boardId }: BoardContentProps) { +function BoardContent({ + boardId, + onlyCurrentUser = false, + emptyMessage = '아직 게시글이 없습니다.', +}: BoardContentProps) { const router = useRouter(); const { clubId } = useParams<{ clubId: string }>(); const currentUserId = useUserId(); @@ -43,6 +49,10 @@ function BoardContent({ boardId }: BoardContentProps) { const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({ rootMargin: '200px', }); + const filteredPosts = + onlyCurrentUser && currentUserId != null + ? posts?.filter((post) => post.author.id === currentUserId) + : posts; useEffect(() => { if (!isError || !error) return; @@ -72,16 +82,16 @@ function BoardContent({ boardId }: BoardContentProps) { ); - if (!posts || posts.length === 0) + if (!filteredPosts || filteredPosts.length === 0) return (
-

아직 게시글이 없습니다.

+

{emptyMessage}

); return (
- {posts.map((post) => ( + {filteredPosts.map((post) => ( item.id === activeId); - const clubName = useClubName(); - - const boardName = clubName ?? '게시판'; const channelName = activeItem?.label ?? ''; // 아이템 선택 후 드롭다운 닫기 diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 09c92fd7a..5ea602ca3 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useParams, usePathname, useRouter } from 'next/navigation'; import { LogoGrayIcon, ExitToAppIcon } from '@/assets/icons'; +import { shouldHideMobileHeaderOnMyPage } from '@/constants/mypage/routes'; import { cn } from '@/lib/cn'; import { useClubName, useUserProfileImageUrl } from '@/stores'; import { PostingActions } from './PostingActions'; @@ -32,6 +33,7 @@ export default function Header({ isMain = true }: HeaderProps) { const profileImageUrl = useUserProfileImageUrl(); const { isAdmin } = useIsAdmin(); const isPostingPage = pathname.includes('/write') || /\/board\/edit\/\d+$/.test(pathname); + const shouldHideMobileHeader = shouldHideMobileHeaderOnMyPage(pathname, clubId); const NAV_ITEMS = [ { id: 'board', label: '게시판', href: `/${clubId}/board` }, @@ -65,56 +67,58 @@ export default function Header({ isMain = true }: HeaderProps) { visible ? 'translate-y-0' : '-translate-y-full', )} > -
- {isMain && ( -
- - {clubName} -
- )} - {isMain && clubId && ( -
- {isPostingPage ? ( - - ) : ( - <> - - {isAdmin && ( + {!shouldHideMobileHeader && ( +
+ {isMain && ( +
+ + {clubName} +
+ )} + {isMain && clubId && ( +
+ {isPostingPage ? ( + + ) : ( + <> + + {isAdmin && ( + + )} - )} - - - )} -
- )} -
+ + )} +
+ )} +
+ )}
diff --git a/src/components/mypage/ActiveClubList.tsx b/src/components/mypage/ActiveClubList.tsx new file mode 100644 index 000000000..e71fc89de --- /dev/null +++ b/src/components/mypage/ActiveClubList.tsx @@ -0,0 +1,100 @@ +import Link from 'next/link'; +import { Avatar, AvatarImage, AvatarFallback, Tag, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import type { ClubDto } from '@/types/mypage'; +import { SettingIcon } from '@/assets/icons'; + +interface ActiveClubListProps extends React.HTMLAttributes { + clubs?: ClubDto[]; + clubId: string; +} + +function ActiveClubList({ clubs = [], clubId, className, ...props }: ActiveClubListProps) { + return ( +
+

+ 사용 중인 프로필 {clubs.length} +

+
+ {clubs.map((club) => ( + + ))} + +
+
+ ); +} + +interface ActiveClubCardProps { + club: ClubDto; +} + +function ActiveClubCard({ club }: ActiveClubCardProps) { + return ( + <> +
+ + + + +
+ {club.name} + {club.description && ( + + {club.description} + + )} + + {club.name} + +
+
+
+ + + + +
+ {club.name} + {club.description && ( + + {club.description} + + )} + + {club.name} + +
+
+ + ); +} + +function ProfileManageCard({ clubId }: { clubId: string }) { + return ( + <> + + + 프로필 관리 + + + + 프로필 관리 + + + ); +} + +export { ActiveClubList, type ActiveClubListProps }; diff --git a/src/components/mypage/AddProfileModal.tsx b/src/components/mypage/AddProfileModal.tsx new file mode 100644 index 000000000..a8c276ea9 --- /dev/null +++ b/src/components/mypage/AddProfileModal.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useAddProfileFlow } from '@/hooks/mypage'; +import { Dialog, DialogContent } from '@/components/ui'; +import { AddProfileModalHeader } from './AddProfileModal/Header'; +import { StepOneContent } from './AddProfileModal/StepOneContent'; +import { StepTwoContent } from './AddProfileModal/StepTwoContent'; + +interface AddProfileModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function AddProfileModal({ open, onOpenChange }: AddProfileModalProps) { + const { + step, + setStep, + selectedClubIds, + control, + errors, + resetFlow, + handleToggleClub, + handleNext, + } = useAddProfileFlow(); + + const handleClose = () => { + resetFlow(); + onOpenChange(false); + }; + + const handleConfirm = () => { + handleClose(); + }; + + return ( + + + + + {step === 1 ? ( + + ) : ( + setStep(1)} + onConfirm={handleConfirm} + /> + )} + + + ); +} + +export { AddProfileModal }; diff --git a/src/components/mypage/AddProfileModal/Header.tsx b/src/components/mypage/AddProfileModal/Header.tsx new file mode 100644 index 000000000..18e17030f --- /dev/null +++ b/src/components/mypage/AddProfileModal/Header.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { DeleteIcon } from '@/assets/icons'; +import { DialogTitle, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; + +const TOTAL_STEPS = 2; + +interface AddProfileModalHeaderProps { + step: number; + title: string; + onClose: () => void; +} + +function AddProfileModalHeader({ step, title, onClose }: AddProfileModalHeaderProps) { + return ( +
+
+
+ {Array.from({ length: TOTAL_STEPS }).map((_, index) => ( +
+ ))} +
+ {title} +
+ +
+ ); +} + +export { AddProfileModalHeader, type AddProfileModalHeaderProps }; diff --git a/src/components/mypage/AddProfileModal/StepOneContent.tsx b/src/components/mypage/AddProfileModal/StepOneContent.tsx new file mode 100644 index 000000000..e14b9511a --- /dev/null +++ b/src/components/mypage/AddProfileModal/StepOneContent.tsx @@ -0,0 +1,133 @@ +'use client'; + +import type { Control, FieldErrors } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; +import { Button, DialogClose, Input } from '@/components/ui'; +import { FormFieldWrapper } from '@/components/auth/hub'; +import { MYPAGE_PROFILE_TEXT_MAX_LENGTH } from '@/constants/mypage/profile'; +import { cn } from '@/lib/cn'; +import type { EditProfileFormData } from '@/lib/schemas/editProfile'; +import { CharacterCountRow } from '../CharacterCountRow'; +import { ProfileBackgroundImageEditor } from '../edit/ProfileBackgroundImageEditor'; +import { ProfileImageEditor } from '../edit/ProfileImageEditor'; + +interface StepOneContentProps { + control: Control>; + errors: FieldErrors>; + onCancel: () => void; + onNext: () => void; + cancelAsDialogClose?: boolean; + mobileFixedFooter?: boolean; +} + +function StepOneContent({ + control, + errors, + onCancel, + onNext, + cancelAsDialogClose = true, + mobileFixedFooter = false, +}: StepOneContentProps) { + const name = useWatch({ control, name: 'name' }) ?? ''; + + return ( + <> + + +
+ +
+
+ ( + +
+ + +
+
+ )} + /> + + ( + +
+ + +
+
+ )} + /> +
+
+ {cancelAsDialogClose ? ( + + + + ) : ( + + )} + +
+ + ); +} + +export { StepOneContent, type StepOneContentProps }; diff --git a/src/components/mypage/AddProfileModal/StepTwoContent.tsx b/src/components/mypage/AddProfileModal/StepTwoContent.tsx new file mode 100644 index 000000000..fe3998953 --- /dev/null +++ b/src/components/mypage/AddProfileModal/StepTwoContent.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { CheckIcon, PeopleIcon } from '@/assets/icons'; +import { Avatar, AvatarFallback, Button, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; + +const MOCK_CLUBS = [ + { id: '1', name: '가천대 검도부', memberCount: 368 }, + { id: '2', name: '가천대 테니스부', memberCount: 368 }, + { id: '3', name: '가천대 산악부', memberCount: 368 }, + { id: '4', name: '가천대 종이접기부', memberCount: 368 }, +]; + +interface StepTwoContentProps { + selectedClubIds: string[]; + onToggleClub: (clubId: string) => void; + onPrev: () => void; + onConfirm: () => void; + mobileFixedFooter?: boolean; +} + +function StepTwoContent({ + selectedClubIds, + onToggleClub, + onPrev, + onConfirm, + mobileFixedFooter = false, +}: StepTwoContentProps) { + return ( +
+
+ {MOCK_CLUBS.map((club) => { + const isSelected = selectedClubIds.includes(club.id); + + return ( + + ); + })} +
+ +
+ + +
+
+ ); +} + +export { StepTwoContent, type StepTwoContentProps }; diff --git a/src/components/mypage/AddProfilePageContent.tsx b/src/components/mypage/AddProfilePageContent.tsx new file mode 100644 index 000000000..067b84192 --- /dev/null +++ b/src/components/mypage/AddProfilePageContent.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { BackIcon } from '@/assets/icons'; +import { useAddProfileFlow } from '@/hooks/mypage'; +import { Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { StepOneContent } from './AddProfileModal/StepOneContent'; +import { StepTwoContent } from './AddProfileModal/StepTwoContent'; + +type AddProfilePageContentProps = React.HTMLAttributes; + +function AddProfilePageContent({ className, ...props }: AddProfilePageContentProps) { + const router = useRouter(); + const { + step, + setStep, + selectedClubIds, + control, + errors, + resetFlow, + handleToggleClub, + handleNext, + } = useAddProfileFlow(); + + const handleClose = () => { + resetFlow(); + router.back(); + }; + + const handleConfirm = () => { + handleClose(); + }; + + return ( +
+
+ +

프로필 추가하기

+
+ +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ + {step === 1 ? ( + + ) : ( + setStep(1)} + onConfirm={handleConfirm} + mobileFixedFooter + /> + )} +
+ ); +} + +export { AddProfilePageContent, type AddProfilePageContentProps }; diff --git a/src/components/mypage/CharacterCountRow.tsx b/src/components/mypage/CharacterCountRow.tsx new file mode 100644 index 000000000..9e7f5530f --- /dev/null +++ b/src/components/mypage/CharacterCountRow.tsx @@ -0,0 +1,22 @@ +interface CharacterCountRowProps { + error?: string; + value: string; + maxLength: number; +} + +function CharacterCountRow({ error, value, maxLength }: CharacterCountRowProps) { + return ( +
+
+ {error ? ( + {error} + ) : null} +
+ + {value.length}/{maxLength} + +
+ ); +} + +export { CharacterCountRow, type CharacterCountRowProps }; diff --git a/src/components/mypage/ClubInfoCard.tsx b/src/components/mypage/ClubInfoCard.tsx index 0623c3e0e..1a3c69e5c 100644 --- a/src/components/mypage/ClubInfoCard.tsx +++ b/src/components/mypage/ClubInfoCard.tsx @@ -1,13 +1,14 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import dynamic from 'next/dynamic'; import { cn } from '@/lib/cn'; import { Avatar, AvatarFallback, AvatarImage, Button, Divider, Icon, Tag } from '@/components/ui'; -import { ExitIcon, PeopleIcon } from '@/assets/icons'; +import { ArrowRightIcon, PeopleIcon } from '@/assets/icons'; import type { ClubDto } from '@/types/mypage'; import { cardClass } from './InfoCard'; +import { LeaveClubDropdownMenu } from './LeaveClubDropdownMenu'; +import { useRouter } from 'next/navigation'; const SetCardinalModal = dynamic(() => import('./SetCardinalModal').then((m) => ({ default: m.SetCardinalModal })), @@ -18,13 +19,14 @@ interface ClubInfoCardProps extends React.HTMLAttributes { } function ClubInfoCard({ club, className }: ClubInfoCardProps) { + const router = useRouter(); const [modalOpen, setModalOpen] = useState(false); return ( <> -
+
-
+
- - - +
-
- {club.name} +
+ + {club.name} + {club.description && ( -

{club.description}

+

+ {club.description} +

)} +
+ + {club.memberCount}명 +
+
+ + {club.profileImageUrl && ( + + )} + + +
+ + {club.name} + + {club.description && ( +

+ {club.description} +

+ )} +
+
-
- - {club.memberCount}명 -
-
- 활동 기수 +
+ 활동 기수
{club.cardinals.length > 0 ? (
@@ -71,17 +94,30 @@ function ClubInfoCard({ club, className }: ClubInfoCardProps) { ))}
) : ( - +
+ + 미설정 + + +
)}
+
diff --git a/src/components/mypage/DeleteProfileDialog.tsx b/src/components/mypage/DeleteProfileDialog.tsx new file mode 100644 index 000000000..f6a859be3 --- /dev/null +++ b/src/components/mypage/DeleteProfileDialog.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; + +interface DeleteProfileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onDelete: () => void; +} + +function DeleteProfileDialog({ open, onOpenChange, onDelete }: DeleteProfileDialogProps) { + return ( + + 삭제 + 취소 + + ); +} + +export { DeleteProfileDialog, type DeleteProfileDialogProps }; diff --git a/src/components/mypage/EditProfileFormContent.tsx b/src/components/mypage/EditProfileFormContent.tsx new file mode 100644 index 000000000..5685493c3 --- /dev/null +++ b/src/components/mypage/EditProfileFormContent.tsx @@ -0,0 +1,102 @@ +'use client'; + +import type { Control, FieldErrors } from 'react-hook-form'; +import { Controller, useWatch } from 'react-hook-form'; +import { FormFieldWrapper } from '@/components/auth/hub'; +import { MYPAGE_PROFILE_TEXT_MAX_LENGTH } from '@/constants/mypage/profile'; +import { Input } from '@/components/ui'; +import type { EditProfileFormData } from '@/lib/schemas/editProfile'; +import { CharacterCountRow } from './CharacterCountRow'; +import { ProfileBackgroundImageEditor } from './edit/ProfileBackgroundImageEditor'; +import { ProfileImageEditor } from './edit/ProfileImageEditor'; + +interface EditProfileFormContentProps { + control: Control>; + errors: FieldErrors>; + fallbackName: string; + profileImageUrl?: string; + className?: string; +} + +function EditProfileFormContent({ + control, + errors, + fallbackName, + profileImageUrl, + className, +}: EditProfileFormContentProps) { + const name = useWatch({ control, name: 'name' }) ?? ''; + + return ( +
+ + +
+ +
+ +
+ ( + +
+ + +
+
+ )} + /> + + ( + +
+ + +
+
+ )} + /> +
+
+ ); +} + +export { EditProfileFormContent, type EditProfileFormContentProps }; diff --git a/src/components/mypage/EditProfileModal.tsx b/src/components/mypage/EditProfileModal.tsx new file mode 100644 index 000000000..39f8c1731 --- /dev/null +++ b/src/components/mypage/EditProfileModal.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState } from 'react'; +import { DeleteIcon } from '@/assets/icons'; +import { Button, Dialog, DialogContent, DialogTitle, Icon } from '@/components/ui'; +import { useEditProfileForm } from '@/hooks/mypage'; +import type { ClubDto } from '@/types/mypage'; +import { DeleteProfileDialog } from './DeleteProfileDialog'; +import { EditProfileFormContent } from './EditProfileFormContent'; + +interface EditProfileModalProps { + open: boolean; + profile: ClubDto; + onOpenChange: (open: boolean) => void; +} + +function EditProfileModal({ open, profile, onOpenChange }: EditProfileModalProps) { + const { + control, + resetToProfile, + name, + formState: { errors }, + } = useEditProfileForm(profile, open); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const handleClose = () => { + resetToProfile(); + setIsDeleteDialogOpen(false); + onOpenChange(false); + }; + + const handleOpenDeleteDialog = () => { + onOpenChange(false); + setIsDeleteDialogOpen(true); + }; + + const handleDelete = () => { + setIsDeleteDialogOpen(false); + handleClose(); + }; + + return ( + + +
+ 프로필 수정 + +
+ + + + + +
+ + +
+
+ + +
+ ); +} + +export { EditProfileModal, type EditProfileModalProps }; diff --git a/src/components/mypage/EditProfilePageContent.tsx b/src/components/mypage/EditProfilePageContent.tsx new file mode 100644 index 000000000..524dbfa32 --- /dev/null +++ b/src/components/mypage/EditProfilePageContent.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { BackIcon } from '@/assets/icons'; +import { Button, Icon } from '@/components/ui'; +import { useEditProfileForm } from '@/hooks/mypage'; +import { useMyPageQueries } from '@/hooks/queries/mypage/useMyPageQueries'; +import { DeleteProfileDialog } from './DeleteProfileDialog'; +import { EditProfileFormContent } from './EditProfileFormContent'; +import { ProfileManagementSkeleton } from './skeleton/ProfileManagementSkeleton'; + +function EditProfilePageContent() { + const router = useRouter(); + const { clubId, profileId } = useParams<{ clubId: string; profileId: string }>(); + const [, { data: clubs = [], isPending }] = useMyPageQueries(clubId); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const profile = useMemo(() => clubs.find((club) => club.id === profileId), [clubs, profileId]); + + const { + control, + resetToProfile, + name, + formState: { errors }, + } = useEditProfileForm(profile ?? null); + + const handleClose = () => { + resetToProfile(); + setIsDeleteDialogOpen(false); + router.back(); + }; + + const handleDelete = () => { + setIsDeleteDialogOpen(false); + handleClose(); + }; + + if (isPending || !profile) { + return ; + } + + return ( + <> +
+
+ +

프로필 수정

+
+ + +
+ +
+ + +
+ + +
+
+ + + + ); +} + +export { EditProfilePageContent }; diff --git a/src/components/mypage/InfoSection.tsx b/src/components/mypage/InfoSection.tsx index 3f29c86ae..c15ea0992 100644 --- a/src/components/mypage/InfoSection.tsx +++ b/src/components/mypage/InfoSection.tsx @@ -9,8 +9,8 @@ interface InfoSectionProps extends React.HTMLAttributes { function InfoSection({ title, children, className, ...props }: InfoSectionProps) { return ( -
-

{title}

+
+

{title}

{children}
); diff --git a/src/components/mypage/LeaveClubDropdownMenu.tsx b/src/components/mypage/LeaveClubDropdownMenu.tsx new file mode 100644 index 000000000..feb9f8149 --- /dev/null +++ b/src/components/mypage/LeaveClubDropdownMenu.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Icon, +} from '@/components/ui'; +import { AdminMeatballIcon } from '@/assets/icons/admin'; +import { WithdrawConfirmDialog } from './WithdrawConfirmDialog'; + +function LeaveClubDropdownMenu() { + const [withdrawOpen, setWithdrawOpen] = useState(false); + + return ( + <> + + + + + + setWithdrawOpen(true)}> + 탈퇴하기 + + + + + + + ); +} + +export { LeaveClubDropdownMenu }; diff --git a/src/components/mypage/LogoutConfirmDialog.tsx b/src/components/mypage/LogoutConfirmDialog.tsx new file mode 100644 index 000000000..73acdb6dc --- /dev/null +++ b/src/components/mypage/LogoutConfirmDialog.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useLogout } from '@/hooks'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; + +interface LogoutConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function LogoutConfirmDialog({ open, onOpenChange }: LogoutConfirmDialogProps) { + const handleLogout = useLogout(); + + return ( + + 로그아웃 + 취소 + + ); +} + +export { LogoutConfirmDialog, type LogoutConfirmDialogProps }; diff --git a/src/components/mypage/MyPageActivityContent.tsx b/src/components/mypage/MyPageActivityContent.tsx new file mode 100644 index 000000000..ca6a14b69 --- /dev/null +++ b/src/components/mypage/MyPageActivityContent.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useResponsiveGridColumns } from '@/hooks'; +import { cn } from '@/lib/cn'; +import { useMyPageQueries } from '@/hooks/queries/mypage/useMyPageQueries'; +import { ClubInfoCard } from './ClubInfoCard'; + +type MyPageActivityContentProps = React.HTMLAttributes; +const TABLET_CARD_WIDTH = 314; +const TABLET_CARD_GAP = 12; + +function MyPageActivityContent({ className, ...props }: MyPageActivityContentProps) { + const { clubId } = useParams<{ clubId: string }>(); + const [, { data: clubs = [] }] = useMyPageQueries(clubId); + const { containerRef, columnCount, isSingleColumn } = useResponsiveGridColumns({ + itemCount: clubs.length, + minColumnWidth: TABLET_CARD_WIDTH, + gap: TABLET_CARD_GAP, + }); + const mobileCardClassName = + clubs.length === 1 + ? 'w-full shrink-0 tablet:w-full' + : cn('w-[250px] shrink-0', isSingleColumn ? 'tablet:w-full' : 'tablet:w-[314px]'); + + return ( +
+

활동정보

+
+
+ {clubs.map((club) => ( + + ))} +
+
+
+ ); +} + +export { MyPageActivityContent, type MyPageActivityContentProps }; diff --git a/src/components/mypage/MyPageContent.tsx b/src/components/mypage/MyPageContent.tsx index 2b59da9af..aee4ec7c3 100644 --- a/src/components/mypage/MyPageContent.tsx +++ b/src/components/mypage/MyPageContent.tsx @@ -1,131 +1,73 @@ 'use client'; import { useParams } from 'next/navigation'; -import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbPage } from '@/components/ui'; import { cn } from '@/lib/cn'; import { useMyPageQueries } from '@/hooks/queries/mypage/useMyPageQueries'; -import { InfoCard } from './InfoCard'; -import { InfoSection } from './InfoSection'; import { ProfileSection } from './ProfileSection'; -import { SupportListItem } from './SupportListItem'; -import { ThemeToggle } from './ThemeToggle'; -import { MyPageDropdownMenu } from './MyPageDropdownMenu'; -import { ClubInfoCard } from './ClubInfoCard'; -import { ProfileSectionSkeleton, InfoCardSkeleton, ClubInfoCardSkeleton } from './skeleton'; +import { ProfileSectionSkeleton } from './skeleton'; +import { ActiveClubList } from './ActiveClubList'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '../ui'; +import { MyPageActivityContent } from './MyPageActivityContent'; +import { MyPageSettingsContent } from './MyPageSettingsContent'; type MyPageContentProps = React.HTMLAttributes; function MyPageContent({ className, ...props }: MyPageContentProps) { const { clubId } = useParams<{ clubId: string }>(); - const [{ data: me, isPending: isMePending }, { data: clubs, isPending: isClubsPending }] = - useMyPageQueries(clubId); + const [{ data: me }, { data: clubs }] = useMyPageQueries(clubId); const displayName = me?.name ?? ''; - return ( -
- {/* PageNavigation */} -
- - - - My - - - + const profileSection = me ? ( + + ) : ( + + ); -
-

My

- -
+ return ( + <> + - - {/* Main Content */} -
- {/* 프로필 */} - {me ? ( - - ) : ( - - )} - - {/* 개인정보 */} - -
- {me && !isMePending ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- - {/* 활동정보 */} - -
- {clubs && !isClubsPending - ? clubs.map((club) => ) - : Array.from({ length: 1 }).map((_, index) => )} +
+
+ + + + + 프로필 + + + + +

프로필

+
+
+
+ {profileSection} +
- - - {/* 서비스 설정 */} - -
-
- 모드 설정 - 다크/라이트 모드 -
- +
+
- - - {/* 고객지원 */} - -
- - - +
+
- +
-
+ ); } diff --git a/src/components/mypage/MyPageDropdownMenu.tsx b/src/components/mypage/MyPageDropdownMenu.tsx index 5017a49f1..09064ad79 100644 --- a/src/components/mypage/MyPageDropdownMenu.tsx +++ b/src/components/mypage/MyPageDropdownMenu.tsx @@ -19,7 +19,6 @@ import { useLogout } from '@/hooks'; function MyPageDropdownMenu() { const { clubId } = useParams<{ clubId: string }>(); - // const [withdrawOpen, setWithdrawOpen] = useState(false); const [logoutOpen, setLogoutOpen] = useState(false); const handleLogout = useLogout(); @@ -40,26 +39,9 @@ function MyPageDropdownMenu() { 개인정보 수정 - setLogoutOpen(true)}>로그아웃 - {/* - setWithdrawOpen(true)}> - 서비스 탈퇴 - */} - {/* TODO: "탈퇴하기"와 "로그아웃" 버튼에 onClick 핸들러 */} - {/* - 탈퇴하기 - 취소 - */} - ) { + const pathname = usePathname(); + const { clubId } = useParams<{ clubId: string }>(); + + return ( + + ); +} + +export { MyPageNav }; diff --git a/src/components/mypage/MyPagePostsContent.tsx b/src/components/mypage/MyPagePostsContent.tsx new file mode 100644 index 000000000..d3a57a093 --- /dev/null +++ b/src/components/mypage/MyPagePostsContent.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { BackIcon } from '@/assets/icons'; +import { Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { BoardContent } from '@/components/board/BoardContent'; + +type MyPagePostsContentProps = React.HTMLAttributes; + +function MyPagePostsContent({ className, ...props }: MyPagePostsContentProps) { + const router = useRouter(); + + return ( +
+
+ +
+

내가 쓴 글

+
+
+ + +
+ ); +} + +export { MyPagePostsContent, type MyPagePostsContentProps }; diff --git a/src/components/mypage/MyPageSessionsContent.tsx b/src/components/mypage/MyPageSessionsContent.tsx new file mode 100644 index 000000000..e534862cb --- /dev/null +++ b/src/components/mypage/MyPageSessionsContent.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { BackIcon, LocationIcon, CalendarIcon } from '@/assets/icons'; +import { Icon, Tag } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { toastError } from '@/stores/useToastStore'; +import type { AttendanceSummary } from '@/types/attendance'; +import { useEffect } from 'react'; +import { formatSessionDateParts } from '@/utils/shared/date'; + +type MyPageSessionsContentProps = React.HTMLAttributes & { + summary?: AttendanceSummary; + errorMessage?: string; +}; + +function MyPageSessionsContent({ + summary, + errorMessage, + className, + ...props +}: MyPageSessionsContentProps) { + const router = useRouter(); + const records = summary?.attendances ?? []; + + useEffect(() => { + if (errorMessage) toastError(errorMessage); + }, [errorMessage]); + + return ( +
+
+ +
+

출석한 세션

+
+
+ +
+ {records.length === 0 ? ( +

+ 출석한 세션이 없습니다. +

+ ) : ( + records.map((record) => { + const { day, weekday, timeLabel } = formatSessionDateParts(record.start); + + return ( +
+
+ {day} + {weekday} +
+ +
+

{record.title}

+
+ 세션 + + + {timeLabel} + + + + {record.location} + +
+
+
+ ); + }) + )} +
+
+ ); +} + +export { MyPageSessionsContent, type MyPageSessionsContentProps }; diff --git a/src/components/mypage/MyPageSettingsContent.tsx b/src/components/mypage/MyPageSettingsContent.tsx new file mode 100644 index 000000000..efda11c54 --- /dev/null +++ b/src/components/mypage/MyPageSettingsContent.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useMediaQuery } from '@/hooks'; +import { cn } from '@/lib/cn'; +import { useThemeStore } from '@/stores/theme-store'; +import type { ThemeMode } from '@/types/theme'; +import { InfoSection } from './InfoSection'; +import { LogoutConfirmDialog } from './LogoutConfirmDialog'; +import { SupportListItem } from './SupportListItem'; +import { ThemeModeModal } from './ThemeModeModal'; +import { WithdrawConfirmDialog } from './WithdrawConfirmDialog'; + +type MyPageSettingsContentProps = React.HTMLAttributes; + +const THEME_MODE_LABELS: Record = { + auto: '자동', + light: '라이트', + dark: '다크', +}; + +function MyPageSettingsContent({ className, ...props }: MyPageSettingsContentProps) { + const router = useRouter(); + const { clubId } = useParams<{ clubId: string }>(); + const isBelowTablet = useMediaQuery('(max-width: 695.98px)'); + const mode = useThemeStore((state) => state.mode); + const hasHydrated = useThemeStore((state) => state.hasHydrated); + const setMode = useThemeStore((state) => state.setMode); + const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); + const [selectedThemeMode, setSelectedThemeMode] = useState('auto'); + const [logoutOpen, setLogoutOpen] = useState(false); + const [withdrawOpen, setWithdrawOpen] = useState(false); + + const handleOpenThemeModal = () => { + if (isBelowTablet) { + router.push(`/${clubId}/mypage/settings/theme`); + return; + } + setSelectedThemeMode(mode); + setIsThemeModalOpen(true); + }; + + const handleConfirmThemeMode = () => { + setMode(selectedThemeMode); + setIsThemeModalOpen(false); + }; + + return ( +
+

서비스 설정

+ + + + + + +
+
+ + +
+ +
+
+
+ +
+ setLogoutOpen(true)} + /> + setWithdrawOpen(true)} + /> +
+ +
+
+
+ + +
+ + + + + + +
+ ); +} + +export { MyPageSettingsContent, type MyPageSettingsContentProps }; diff --git a/src/components/mypage/ProfileCard.tsx b/src/components/mypage/ProfileCard.tsx new file mode 100644 index 000000000..724076964 --- /dev/null +++ b/src/components/mypage/ProfileCard.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { EditIcon, InfoCircleIcon } from '@/assets/icons'; +import { useMediaQuery } from '@/hooks'; +import { Avatar, AvatarFallback, AvatarImage, Button, Icon } from '@/components/ui'; +import type { ClubDto } from '@/types/mypage'; +import { EditProfileModal } from './EditProfileModal'; +import { ProfileSelectModal } from './ProfileSelectModal'; + +interface ProfileCardProps { + profile: ClubDto; + clubs: ClubDto[]; + clubId: string; + availableProfiles: ClubDto[]; +} + +function ProfileCard({ profile, clubs, clubId, availableProfiles }: ProfileCardProps) { + const router = useRouter(); + const isBelowTablet = useMediaQuery('(max-width: 695.98px)'); + const [selectedClub, setSelectedClub] = useState(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const handleOpenEditProfile = () => { + if (isBelowTablet) { + router.push(`/${clubId}/mypage/profiles/${profile.id}/edit`); + return; + } + + setIsEditModalOpen(true); + }; + + return ( + <> +
+
+ + + + +
+

{profile.name}

+ {profile.description && ( +

{profile.description}

+ )} +
+ +
+ + {clubs.length === 0 ? ( +
+ + 사용하는 동아리가 없습니다. +
+ ) : ( +
+ {clubs.map((club) => ( +
+
+ + + + + {club.name} +
+ +
+ ))} +
+ )} +
+ + {selectedClub && ( + { + if (!open) setSelectedClub(null); + }} + /> + )} + + + + ); +} + +export { ProfileCard, type ProfileCardProps }; diff --git a/src/components/mypage/ProfileManagementContent.tsx b/src/components/mypage/ProfileManagementContent.tsx new file mode 100644 index 000000000..5f1f6f1e5 --- /dev/null +++ b/src/components/mypage/ProfileManagementContent.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useMediaQuery } from '@/hooks'; +import { Button, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import { useMyPageQueries } from '@/hooks/queries/mypage/useMyPageQueries'; +import { AddRoundIcon, BackIcon } from '@/assets/icons'; +import { useRouter } from 'next/navigation'; +import { AddProfileModal } from './AddProfileModal'; +import { ProfileCard } from './ProfileCard'; + +type ProfileManagementContentProps = React.HTMLAttributes; + +function ProfileManagementContent({ className, ...props }: ProfileManagementContentProps) { + const router = useRouter(); + const { clubId } = useParams<{ clubId: string }>(); + const isBelowTablet = useMediaQuery('(max-width: 695.98px)'); + const [, { data: clubs = [] }] = useMyPageQueries(clubId); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + const handleOpenAddProfile = () => { + if (isBelowTablet) { + router.push(`/${clubId}/mypage/profiles/add`); + return; + } + + setIsAddModalOpen(true); + }; + + return ( +
+
+ +
+

프로필 관리

+

+ 같은 프로필을 사용하는 동아리에는 프로필 사진, 이름, 소개글이 함께 반영돼요. +

+
+
+
+ 같은 프로필을 사용하는 동아리에는 프로필 사진, 이름, 소개글이 함께 반영돼요. +
+ +
+

사용 중인 프로필 {clubs.length}

+
+ {clubs.map((club) => ( + + ))} + {/* TODO: mock — 빈 프로필 empty state 확인용 */} + + {/* TODO: mock — 클럽 2개 확인용 */} + +
+ +
+ + +
+ ); +} +export { ProfileManagementContent, type ProfileManagementContentProps }; diff --git a/src/components/mypage/ProfileSection.tsx b/src/components/mypage/ProfileSection.tsx index 20487da14..570dca288 100644 --- a/src/components/mypage/ProfileSection.tsx +++ b/src/components/mypage/ProfileSection.tsx @@ -1,30 +1,149 @@ -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui'; +'use client'; + +import { Fragment } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Icon } from '@/components/ui'; +import { ArrowRightIcon, PhoneIcon, MailIcon } from '@/assets/icons'; import { cn } from '@/lib/cn'; +import type { ProfileData } from '@/types/mypage'; +import { ProfileBackgroundImageEditor } from './edit/ProfileBackgroundImageEditor'; +import { ProfileImageEditor } from './edit/ProfileImageEditor'; +import { MyPageDropdownMenu } from './MyPageDropdownMenu'; -interface ProfileSectionProps extends React.HTMLAttributes { - name: string; - bio?: string; - profileImageUrl?: string; +interface ProfileSectionProps extends React.HTMLAttributes, ProfileData { + postCount?: number; + sessionCount?: number; } -function ProfileSection({ name, bio, profileImageUrl, className, ...props }: ProfileSectionProps) { +const ProfileSection = ({ + name, + bio, + profileImageUrl, + tel, + email, + school, + department, + postCount = 0, + sessionCount = 0, + className, + ...props +}: ProfileSectionProps) => { + const router = useRouter(); + const { clubId } = useParams<{ clubId: string }>(); + const schoolLabel = [school, department].filter(Boolean).join(' · '); + const activityItems = [ + { + label: '내가 쓴 글', + count: postCount, + href: `/${clubId}/mypage/posts`, + }, + { + label: '출석한 세션', + count: sessionCount, + href: `/${clubId}/mypage/sessions`, + }, + ] as const; + return ( -
- - - - -
-

{name}

- {bio &&

{bio}

} +
+ + +
+
+ + +
+ +
+
+ +
+
+

{name}

+ {schoolLabel && ( + + {schoolLabel} + + )} +
+ {bio &&

{bio}

} + {(tel || email) && ( +
+ {tel && ( + + + {tel} + + )} + {email && ( + + + {email} + + )} +
+ )} + {schoolLabel && ( + + {schoolLabel} + + )} +
+ +
+ {activityItems.map((item, index) => ( + + {index > 0 &&
} + + + ))} +
); -} +}; export { ProfileSection, type ProfileSectionProps }; diff --git a/src/components/mypage/ProfileSelectModal.tsx b/src/components/mypage/ProfileSelectModal.tsx new file mode 100644 index 000000000..bde79d3a7 --- /dev/null +++ b/src/components/mypage/ProfileSelectModal.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { CheckIcon, DeleteIcon } from '@/assets/icons'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogContent, + DialogTitle, + Icon, +} from '@/components/ui'; +import { cn } from '@/lib/cn'; +import type { ClubDto } from '@/types/mypage'; + +interface ProfileSelectModalProps { + open: boolean; + club: ClubDto; + currentProfileId: string; + profiles: ClubDto[]; + onOpenChange: (open: boolean) => void; + onConfirm?: (profileId: string) => void; +} + +function ProfileSelectModal({ + open, + club, + currentProfileId, + profiles, + onOpenChange, + onConfirm, +}: ProfileSelectModalProps) { + const [selectedProfileId, setSelectedProfileId] = useState(currentProfileId); + + const handleClose = () => { + onOpenChange(false); + }; + + const handleConfirm = () => { + onConfirm?.(selectedProfileId); + onOpenChange(false); + }; + + return ( + + +
+
+ + + + + + {club.name}에서
+ 사용할 프로필을 선택하세요. +
+
+ +
+ +
+ {profiles.map((profile) => { + const isSelected = profile.id === selectedProfileId; + + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} + +export { ProfileSelectModal, type ProfileSelectModalProps }; diff --git a/src/components/mypage/SetCardinalModal/index.tsx b/src/components/mypage/SetCardinalModal/index.tsx index 089d647c4..e7bf32ab4 100644 --- a/src/components/mypage/SetCardinalModal/index.tsx +++ b/src/components/mypage/SetCardinalModal/index.tsx @@ -88,7 +88,7 @@ function SetCardinalModal({ open, onOpenChange, club, onSave }: SetCardinalModal ]; const { title, body, footer } = STEPS[step - 1]; - const overline = `'${club.name}' 활동 기수 설정`; + const overline = `${club.name}`; return ( diff --git a/src/components/mypage/SupportListItem.tsx b/src/components/mypage/SupportListItem.tsx index 4295a94ab..68488266c 100644 --- a/src/components/mypage/SupportListItem.tsx +++ b/src/components/mypage/SupportListItem.tsx @@ -10,6 +10,7 @@ interface SupportListItemProps extends React.HTMLAttributes { variant?: 'link' | 'copy'; href?: string; copyText?: string; + layout?: 'card' | 'row'; } function SupportListItem({ @@ -18,30 +19,68 @@ function SupportListItem({ variant = 'link', href, copyText, + layout = 'card', className, + onClick, ...props }: SupportListItemProps) { + const hasSubContent = !!description || variant === 'copy'; + const isRow = layout === 'row'; + + const renderAction = () => { + if (isRow && variant === 'copy') { + return ( + + 복사하기 + + ); + } + + if (variant === 'link') { + return ( + + ); + } + + return null; + }; + const content = ( <> -
- {title} -
- {description &&

{description}

} - {variant === 'copy' && ( - +
+
+ {title} + {!isRow && hasSubContent && ( +
+ {description &&

{description}

} + {variant === 'copy' && ( + + )} +
)}
-
- - {variant === 'link' && ( - + + {isRow ? ( + renderAction() + ) : ( + + {renderAction()} + )} - +
); const baseClass = cn( - 'bg-container-neutral relative flex w-full cursor-pointer flex-col items-start rounded-lg p-400 text-left', + isRow + ? 'relative flex w-full cursor-pointer flex-col items-start px-400 py-300 text-left' + : 'bg-container-neutral relative flex w-full cursor-pointer flex-col items-start rounded-lg px-400 py-300 text-left', className, ); @@ -53,11 +92,12 @@ function SupportListItem({ ); } - const handleClick = () => { + const handleClick = (e: React.MouseEvent) => { if (copyText) { navigator.clipboard.writeText(copyText); toastSuccess('복사되었습니다!'); } + onClick?.(e); }; return ( diff --git a/src/components/mypage/ThemeModeModal.tsx b/src/components/mypage/ThemeModeModal.tsx new file mode 100644 index 000000000..26a68eccc --- /dev/null +++ b/src/components/mypage/ThemeModeModal.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { DeleteIcon } from '@/assets/icons'; +import { Button, Dialog, DialogContent, DialogTitle, Icon } from '@/components/ui'; +import type { ThemeMode } from '@/types/theme'; +import { ThemeModeSelector } from './ThemeModeSelector'; + +interface ThemeModeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedMode: ThemeMode; + onSelectMode: (mode: ThemeMode) => void; + onConfirm: () => void; + disabled?: boolean; +} + +function ThemeModeModal({ + open, + onOpenChange, + selectedMode, + onSelectMode, + onConfirm, + disabled = false, +}: ThemeModeModalProps) { + return ( + + +
+ 화면 모드 + +
+ + +
+ + +
+
+
+ ); +} + +export { ThemeModeModal, type ThemeModeModalProps }; diff --git a/src/components/mypage/ThemeModePageContent.tsx b/src/components/mypage/ThemeModePageContent.tsx new file mode 100644 index 000000000..68b2d19b1 --- /dev/null +++ b/src/components/mypage/ThemeModePageContent.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { BackIcon } from '@/assets/icons'; +import { useThemeStore } from '@/stores/theme-store'; +import { Button, Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import type { ThemeMode } from '@/types/theme'; +import { ThemeModeSelector } from './ThemeModeSelector'; + +type ThemeModePageContentProps = React.HTMLAttributes; + +function ThemeModePageContent({ className, ...props }: ThemeModePageContentProps) { + const router = useRouter(); + const mode = useThemeStore((state) => state.mode); + const hasHydrated = useThemeStore((state) => state.hasHydrated); + const setMode = useThemeStore((state) => state.setMode); + const [selectedMode, setSelectedMode] = useState(mode); + + useEffect(() => { + setSelectedMode(mode); + }, [mode]); + + const handleConfirm = () => { + setMode(selectedMode); + router.back(); + }; + + return ( +
+
+ +

화면 모드

+
+ + + +
+ +
+
+ ); +} + +export { ThemeModePageContent, type ThemeModePageContentProps }; diff --git a/src/components/mypage/ThemeModeSelector.tsx b/src/components/mypage/ThemeModeSelector.tsx new file mode 100644 index 000000000..8f322f05b --- /dev/null +++ b/src/components/mypage/ThemeModeSelector.tsx @@ -0,0 +1,114 @@ +'use client'; + +import Image, { type StaticImageData } from 'next/image'; +import { CheckIcon } from '@/assets/icons'; +import ThemeAutoImage from '@/assets/image/theme/theme_auto.png'; +import ThemeDarkImage from '@/assets/image/theme/theme_dark.png'; +import ThemeLightImage from '@/assets/image/theme/theme_light.png'; +import { Icon } from '@/components/ui'; +import { cn } from '@/lib/cn'; +import type { ThemeMode } from '@/types/theme'; + +const THEME_MODE_OPTIONS: { + value: ThemeMode; + label: string; + previewImage: StaticImageData; +}[] = [ + { value: 'auto', label: '자동', previewImage: ThemeAutoImage }, + { value: 'light', label: '라이트', previewImage: ThemeLightImage }, + { value: 'dark', label: '다크', previewImage: ThemeDarkImage }, +]; + +interface ThemeModeSelectorProps { + selectedMode: ThemeMode; + onSelectMode: (mode: ThemeMode) => void; + disabled?: boolean; + className?: string; + layout?: 'default' | 'mobile-page'; +} + +function ThemePreviewCard({ + selected, + option, +}: { + selected: boolean; + option: (typeof THEME_MODE_OPTIONS)[number]; +}) { + return ( +
+
+ {`${option.label} +
+ + + {option.label} + + + +
+ ); +} + +function ThemeModeSelector({ + selectedMode, + onSelectMode, + disabled = false, + className, + layout = 'default', +}: ThemeModeSelectorProps) { + const renderOption = (option: (typeof THEME_MODE_OPTIONS)[number]) => { + const selected = selectedMode === option.value; + + return ( + + ); + }; + + if (layout === 'mobile-page') { + const [autoOption, ...restOptions] = THEME_MODE_OPTIONS; + + return ( +
+
{renderOption(autoOption)}
+
+ {restOptions.map((option) => renderOption(option))} +
+
+ ); + } + + return ( +
+ {THEME_MODE_OPTIONS.map((option) => renderOption(option))} +
+ ); +} + +export { ThemeModeSelector, type ThemeModeSelectorProps, type ThemeMode }; diff --git a/src/components/mypage/ThemeToggle.tsx b/src/components/mypage/ThemeToggle.tsx index 9392083f3..1bdcd1b25 100644 --- a/src/components/mypage/ThemeToggle.tsx +++ b/src/components/mypage/ThemeToggle.tsx @@ -2,8 +2,7 @@ import { cn } from '@/lib/cn'; import { useThemeStore } from '@/stores/theme-store'; - -type ThemeMode = 'auto' | 'light' | 'dark'; +import type { ThemeMode } from '@/types/theme'; const OPTIONS: { value: ThemeMode; label: string }[] = [ { value: 'auto', label: '자동' }, diff --git a/src/components/mypage/WithdrawConfirmDialog.tsx b/src/components/mypage/WithdrawConfirmDialog.tsx new file mode 100644 index 000000000..bf3b46090 --- /dev/null +++ b/src/components/mypage/WithdrawConfirmDialog.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { AlertDialog, AlertDialogAction, AlertDialogCancel } from '@/components/ui'; + +interface WithdrawConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm?: () => void; +} + +function WithdrawConfirmDialog({ open, onOpenChange, onConfirm }: WithdrawConfirmDialogProps) { + const handleConfirm = () => { + onConfirm?.(); + onOpenChange(false); + }; + + return ( + + 탈퇴하기 + 취소 + + ); +} + +export { WithdrawConfirmDialog, type WithdrawConfirmDialogProps }; diff --git a/src/components/mypage/edit/EditProfileContent.tsx b/src/components/mypage/edit/EditProfileContent.tsx index 0bdb1effb..a5290c083 100644 --- a/src/components/mypage/edit/EditProfileContent.tsx +++ b/src/components/mypage/edit/EditProfileContent.tsx @@ -1,23 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { isAxiosError } from 'axios'; -import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useForm, useWatch } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, - Button, -} from '@/components/ui'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, Button, Icon } from '@/components/ui'; import { useNavigationGuard } from '@/hooks/useNavigationGuard'; import { cn } from '@/lib/cn'; import { createEditProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; @@ -26,9 +14,9 @@ import { useUpdateProfileMutation } from '@/hooks/mutations/useUpdateProfileMuta import { toastSuccess, toastError } from '@/stores/useToastStore'; import { formatPhone } from '@/utils/shared'; import { EditProfileSkeleton } from '@/components/mypage/skeleton'; -import { ProfileImageEditor } from './ProfileImageEditor'; import { PersonalInfoFields } from './PersonalInfoFields'; import { SchoolInfoFields } from './SchoolInfoFields'; +import { BackIcon } from '@/assets/icons'; const toFormString = (value: string | null | undefined) => value ?? ''; @@ -42,8 +30,8 @@ function EditProfileContent({ className, schools, majors, ...props }: EditProfil const { clubId } = useParams<{ clubId: string }>(); const { data: me, isPending: isMePending } = useMyMemberQuery(clubId); const { mutate: updateProfile, isPending } = useUpdateProfileMutation(); - const [selectedFile, setSelectedFile] = useState(null); - const [resetToDefault, setResetToDefault] = useState(false); + const selectedFile: File | null = null; + const resetToDefault = false; const editProfileSchema = createEditProfileSchema(); const { @@ -84,7 +72,6 @@ function EditProfileContent({ className, schools, majors, ...props }: EditProfil void trigger(['phone', 'school', 'department', 'studentId']); }, [me, reset, trigger]); - const name = useWatch({ control, name: 'name' }); const hasChanges = isDirty || !!selectedFile || resetToDefault; const { open, onConfirm, onCancel, allowNavigation } = useNavigationGuard({ @@ -125,6 +112,7 @@ function EditProfileContent({ className, schools, majors, ...props }: EditProfil }, ); }; + const submitForm = handleSubmit(onSubmit); if (isMePending || !me) { return ; @@ -134,48 +122,25 @@ function EditProfileContent({ className, schools, majors, ...props }: EditProfil <>
-
- - - - - - My - - - - - - 개인정보 수정 - - - - -

개인정보 수정

+
+ +
+

개인정보 수정

+
-
- { - setSelectedFile(file); - setResetToDefault(false); - }} - onResetImage={() => { - setSelectedFile(null); - setResetToDefault(true); - }} - /> - +
@@ -185,13 +150,24 @@ function EditProfileContent({ className, schools, majors, ...props }: EditProfil type="submit" size="lg" disabled={isPending || !isValid || !hasChanges} - className="w-full" + className="tablet:flex hidden w-full" > {isPending ? '수정 중...' : '수정 완료'}
+
+ +
; } -function CharacterCountRow({ error, value }: { error?: string; value: string }) { - return ( -
-
- {error ? ( - {error} - ) : null} -
- - {value.length}/30 - -
- ); -} - function PersonalInfoFields({ control }: PersonalInfoFieldsProps) { const { errors } = useFormState({ control }); return (
- - ( - <> - - - - )} - /> - - - - ( - <> - - - - )} - /> - - void; + onResetImage?: () => void; + priority?: boolean; + className?: string; + imageClassName?: string; + triggerClassName?: string; + triggerIconClassName?: string; + triggerIconSize?: number; +} + +function ProfileBackgroundImageEditor({ + backgroundImageUrl, + onFileChange, + onResetImage, + priority = false, + className, + imageClassName, + triggerClassName, + triggerIconClassName, + triggerIconSize = 16, +}: ProfileBackgroundImageEditorProps) { + const { fileInputRef, displayUrl, isPreview, handleChange, handleReset } = useImagePreview({ + initialImageUrl: backgroundImageUrl, + onFileChange, + onResetImage, + }); + + return ( +
+ {displayUrl && ( + + )} +
+ + + + + + + fileInputRef.current?.click()} + > + 이미지 업로드 + + + + 기본 이미지 + + + +
+
+ ); +} + +export { ProfileBackgroundImageEditor, type ProfileBackgroundImageEditorProps }; diff --git a/src/components/mypage/edit/ProfileImageEditor.tsx b/src/components/mypage/edit/ProfileImageEditor.tsx index 237f27b87..247d16b15 100644 --- a/src/components/mypage/edit/ProfileImageEditor.tsx +++ b/src/components/mypage/edit/ProfileImageEditor.tsx @@ -1,24 +1,33 @@ 'use client'; -import { useRef, useState } from 'react'; import { - Avatar, - AvatarFallback, - AvatarImage, Icon, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, Divider, + Avatar, + AvatarFallback, + AvatarImage, } from '@/components/ui'; -import { EditIcon } from '@/assets/icons'; +import { CameraIcon } from '@/assets/icons'; +import { useImagePreview } from '@/hooks/mypage'; +import { cn } from '@/lib/cn'; +import type { AvatarProps } from '@/components/ui'; interface ProfileImageEditorProps { name: string; profileImageUrl?: string; onFileChange?: (file: File) => void; onResetImage?: () => void; + className?: string; + avatarSize?: AvatarProps['size']; + avatarClassName?: string; + fallbackClassName?: string; + triggerClassName?: string; + triggerIconClassName?: string; + triggerIconSize?: number; } function ProfileImageEditor({ @@ -26,45 +35,30 @@ function ProfileImageEditor({ profileImageUrl, onFileChange, onResetImage, + className, + avatarSize = 128, + avatarClassName, + fallbackClassName, + triggerClassName, + triggerIconClassName, + triggerIconSize = 16, }: ProfileImageEditorProps) { - const fileInputRef = useRef(null); - const previewUrlRef = useRef(null); - const [previewUrl, setPreviewUrl] = useState(null); - const [isReset, setIsReset] = useState(false); - - const handleChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); - const url = URL.createObjectURL(file); - previewUrlRef.current = url; - setPreviewUrl(url); - setIsReset(false); - onFileChange?.(file); - }; - - const handleReset = () => { - if (previewUrlRef.current) { - URL.revokeObjectURL(previewUrlRef.current); - previewUrlRef.current = null; - } - setPreviewUrl(null); - setIsReset(true); - onResetImage?.(); - }; - - const displayUrl = isReset ? null : (previewUrl ?? profileImageUrl ?? null); + const { fileInputRef, displayUrl, handleChange, handleReset } = useImagePreview({ + initialImageUrl: profileImageUrl, + onFileChange, + onResetImage, + }); return ( -
- +
+ - + - + diff --git a/src/components/mypage/index.ts b/src/components/mypage/index.ts index 59c39b863..21444a5a7 100644 --- a/src/components/mypage/index.ts +++ b/src/components/mypage/index.ts @@ -1,10 +1,25 @@ export { MyPageContent, type MyPageContentProps } from './MyPageContent'; +export { MyPageActivityContent, type MyPageActivityContentProps } from './MyPageActivityContent'; +export { MyPagePostsContent, type MyPagePostsContentProps } from './MyPagePostsContent'; +export { MyPageSessionsContent, type MyPageSessionsContentProps } from './MyPageSessionsContent'; +export { MyPageSettingsContent, type MyPageSettingsContentProps } from './MyPageSettingsContent'; +export { + ProfileManagementContent, + type ProfileManagementContentProps, +} from './ProfileManagementContent'; +export { ActiveClubList, type ActiveClubListProps } from './ActiveClubList'; +export { AddProfilePageContent, type AddProfilePageContentProps } from './AddProfilePageContent'; +export { DeleteProfileDialog, type DeleteProfileDialogProps } from './DeleteProfileDialog'; +export { EditProfilePageContent } from './EditProfilePageContent'; export { ProfileSection, type ProfileSectionProps } from './ProfileSection'; export { InfoSection, type InfoSectionProps } from './InfoSection'; export { InfoCard, type InfoCardProps, type InfoCardItem } from './InfoCard'; export { ThemeToggle, type ThemeToggleProps } from './ThemeToggle'; +export { ThemeModePageContent, type ThemeModePageContentProps } from './ThemeModePageContent'; +export { LogoutConfirmDialog, type LogoutConfirmDialogProps } from './LogoutConfirmDialog'; export { SupportListItem, type SupportListItemProps } from './SupportListItem'; export { MyPageDropdownMenu } from './MyPageDropdownMenu'; +export { WithdrawConfirmDialog, type WithdrawConfirmDialogProps } from './WithdrawConfirmDialog'; export { EditProfileContent, type EditProfileContentProps } from './edit'; export { FormField, type FormFieldProps } from './FormField'; export { SearchSelect, type SearchSelectProps } from './SearchSelect'; @@ -12,6 +27,8 @@ export { ClubInfoCard, type ClubInfoCardProps } from './ClubInfoCard'; export { SetCardinalModal, type SetCardinalModalProps } from './SetCardinalModal'; export { MyPageSkeleton, + MyPageActivitySkeleton, + MyPageSettingsSkeleton, ProfileSectionSkeleton, InfoCardSkeleton, ClubInfoCardSkeleton, diff --git a/src/components/mypage/skeleton/EditProfileSkeleton.tsx b/src/components/mypage/skeleton/EditProfileSkeleton.tsx index 6093d36a7..d6f3fa4e8 100644 --- a/src/components/mypage/skeleton/EditProfileSkeleton.tsx +++ b/src/components/mypage/skeleton/EditProfileSkeleton.tsx @@ -7,39 +7,51 @@ function EditProfileSkeleton({ className, ...props }: EditProfileSkeletonProps) return (
-
-
- - - +
+ +
+
-
-
- - -
-
-
- - - - -
-
- - - - +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
diff --git a/src/components/mypage/skeleton/MyPageActivitySkeleton.tsx b/src/components/mypage/skeleton/MyPageActivitySkeleton.tsx new file mode 100644 index 000000000..2081702c4 --- /dev/null +++ b/src/components/mypage/skeleton/MyPageActivitySkeleton.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@/components/ui'; +import { ClubInfoCardSkeleton } from './MyPageContentSkeletons'; + +function MyPageActivitySkeleton() { + return ( +
+
+ +
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+
+ ); +} + +export { MyPageActivitySkeleton }; diff --git a/src/components/mypage/skeleton/MyPageContentSkeletons.tsx b/src/components/mypage/skeleton/MyPageContentSkeletons.tsx index 168a5ce77..1718c8d03 100644 --- a/src/components/mypage/skeleton/MyPageContentSkeletons.tsx +++ b/src/components/mypage/skeleton/MyPageContentSkeletons.tsx @@ -2,11 +2,38 @@ import { Skeleton } from '@/components/ui'; function ProfileSectionSkeleton() { return ( -
- -
- - +
+ +
+
+ +
+ + +
+
+
+
+ + +
+ + +
+
+ +
+
+
+ + +
+
+
+ + +
+
); diff --git a/src/components/mypage/skeleton/MyPageSessionsSkeleton.tsx b/src/components/mypage/skeleton/MyPageSessionsSkeleton.tsx new file mode 100644 index 000000000..1a923ccaf --- /dev/null +++ b/src/components/mypage/skeleton/MyPageSessionsSkeleton.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from '@/components/ui'; + +function MyPageSessionsSkeleton() { + return ( +
+
+ +
+ +
+
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+ + +
+ +
+ +
+ + + +
+
+
+ ))} +
+
+ ); +} + +export { MyPageSessionsSkeleton }; diff --git a/src/components/mypage/skeleton/MyPageSettingsSkeleton.tsx b/src/components/mypage/skeleton/MyPageSettingsSkeleton.tsx new file mode 100644 index 000000000..d62b33f31 --- /dev/null +++ b/src/components/mypage/skeleton/MyPageSettingsSkeleton.tsx @@ -0,0 +1,58 @@ +import { Skeleton } from '@/components/ui'; + +function SettingsRowSkeleton({ trailing = 'arrow' }: { trailing?: 'arrow' | 'button' }) { + return ( +
+ + {trailing === 'button' ? ( + + ) : ( + + )} +
+ ); +} + +function MyPageSettingsSkeleton() { + return ( +
+ + +
+ +
+
+ +
+
+
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ ); +} + +export { MyPageSettingsSkeleton }; diff --git a/src/components/mypage/skeleton/ProfileManagementSkeleton.tsx b/src/components/mypage/skeleton/ProfileManagementSkeleton.tsx new file mode 100644 index 000000000..0b2e0b908 --- /dev/null +++ b/src/components/mypage/skeleton/ProfileManagementSkeleton.tsx @@ -0,0 +1,57 @@ +import { Skeleton } from '@/components/ui'; + +function ProfileManagementCardSkeleton() { + return ( +
+
+ +
+ + +
+ +
+ +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+ ); +} + +function ProfileManagementSkeleton() { + return ( +
+
+ +
+ + +
+
+ +
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ +
+
+ ); +} + +export { ProfileManagementSkeleton }; diff --git a/src/components/mypage/skeleton/index.ts b/src/components/mypage/skeleton/index.ts index bc98380f2..57647a9e7 100644 --- a/src/components/mypage/skeleton/index.ts +++ b/src/components/mypage/skeleton/index.ts @@ -1,4 +1,6 @@ export { MyPageSkeleton } from './MyPageSkeleton'; +export { MyPageActivitySkeleton } from './MyPageActivitySkeleton'; +export { MyPageSettingsSkeleton } from './MyPageSettingsSkeleton'; export { ProfileSectionSkeleton, InfoCardSkeleton, diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index cf69a87bc..7a4cca9fa 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -7,11 +7,13 @@ const buttonVariants = cva( variants: { variant: { primary: - 'bg-button-primary text-text-inverse hover:bg-button-primary-interaction active:bg-button-primary-interaction disabled:bg-button-neutral disabled:text-text-disabled', + 'bg-button-primary text-text-inverse hover:bg-button-primary-interaction active:bg-button-primary-interaction disabled:bg-button-disabled disabled:text-text-disabled', secondary: - 'bg-button-neutral text-text-normal hover:bg-button-neutral-interaction active:bg-button-neutral-interaction disabled:bg-button-neutral disabled:text-text-disabled', + 'bg-button-neutral text-text-normal hover:bg-button-neutral-interaction active:bg-button-neutral-interaction disabled:bg-button-disabled disabled:text-text-disabled', tertiary: 'bg-transparent text-text-normal hover:bg-container-neutral-interaction active:bg-container-neutral-interaction disabled:text-text-disabled', + primarySoft: + 'bg-button-primary-subtle text-brand-primary hover:bg-button-primary-subtle-interaction active:bg-button-primary-subtle-interaction disabled:bg-button-disabled disabled:text-text-disabled', danger: 'bg-state-error text-text-inverse hover:opacity-90 active:opacity-80 disabled:bg-button-neutral disabled:text-text-disabled', kakao: diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 71761e12d..475e97b59 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -13,8 +13,10 @@ const avatarVariants = cva('group/avatar relative flex shrink-0 overflow-hidden }, size: { 128: 'size-32', + 100: 'size-25', 64: 'size-16', 40: 'size-10', + 36: 'size-9', 24: 'size-6', }, colorScheme: { @@ -25,8 +27,10 @@ const avatarVariants = cva('group/avatar relative flex shrink-0 overflow-hidden }, compoundVariants: [ { type: 'square', size: 128, className: 'rounded-[32px]' }, + { type: 'square', size: 100, className: 'rounded-[25px]' }, { type: 'square', size: 64, className: 'rounded-lg' }, { type: 'square', size: 40, className: 'rounded-md' }, + { type: 'square', size: 36, className: 'rounded-md' }, { type: 'square', size: 24, className: 'rounded-[6px]' }, ], defaultVariants: { diff --git a/src/constants/mypage/profile.ts b/src/constants/mypage/profile.ts new file mode 100644 index 000000000..01fa4a94e --- /dev/null +++ b/src/constants/mypage/profile.ts @@ -0,0 +1 @@ +export const MYPAGE_PROFILE_TEXT_MAX_LENGTH = 30; diff --git a/src/constants/mypage/routes.ts b/src/constants/mypage/routes.ts new file mode 100644 index 000000000..c5b4c84c0 --- /dev/null +++ b/src/constants/mypage/routes.ts @@ -0,0 +1,41 @@ +const MYPAGE_PROFILE_SUB_PATHS = [ + '/mypage/profiles', + '/mypage/posts', + '/mypage/sessions', + '/mypage/edit', +] as const; + +const MYPAGE_SETTINGS_SUB_PATHS = ['/mypage/settings'] as const; + +const MOBILE_HEADER_HIDDEN_MYPAGE_PATHS = [ + '/mypage/profiles', + '/mypage/posts', + '/mypage/sessions', + '/mypage/edit', + '/mypage/settings/theme', + '/mypage/profiles/add', +] as const; + +function isMyPageProfileSubPath(pathname: string, clubId: string) { + return MYPAGE_PROFILE_SUB_PATHS.some((path) => pathname.startsWith(`/${clubId}${path}`)); +} + +function isMyPageSettingsSubPath(pathname: string, clubId: string) { + return MYPAGE_SETTINGS_SUB_PATHS.some((path) => pathname.startsWith(`/${clubId}${path}/`)); +} + +function shouldHideMobileHeaderOnMyPage(pathname: string, clubId: string) { + return ( + MOBILE_HEADER_HIDDEN_MYPAGE_PATHS.some((path) => pathname.startsWith(`/${clubId}${path}`)) || + new RegExp(`^/${clubId}/mypage/profiles/[^/]+/edit$`).test(pathname) + ); +} + +export { + MYPAGE_PROFILE_SUB_PATHS, + MYPAGE_SETTINGS_SUB_PATHS, + MOBILE_HEADER_HIDDEN_MYPAGE_PATHS, + isMyPageProfileSubPath, + isMyPageSettingsSubPath, + shouldHideMobileHeaderOnMyPage, +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 412d4d44c..663f744ce 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -24,5 +24,6 @@ export { useProgressAnimation } from './useProgressAnimation'; export { useCodeHighlight } from './useCodeHighlight'; export { useMonthNavigator } from './useMonthNavigator'; export { useMediaQuery } from './useMediaQuery'; +export { useResponsiveGridColumns } from './useResponsiveGridColumns'; export { useResetKeyOnOpen } from './useResetKeyOnOpen'; export { useSyncFormToStore } from './useSyncFormToStore'; diff --git a/src/hooks/mypage/index.ts b/src/hooks/mypage/index.ts new file mode 100644 index 000000000..60fdaba87 --- /dev/null +++ b/src/hooks/mypage/index.ts @@ -0,0 +1,3 @@ +export { useAddProfileFlow } from './useAddProfileFlow'; +export { useEditProfileForm, type EditProfileFormValuesSource } from './useEditProfileForm'; +export { useImagePreview, type UseImagePreviewOptions } from './useImagePreview'; diff --git a/src/hooks/mypage/useAddProfileFlow.ts b/src/hooks/mypage/useAddProfileFlow.ts new file mode 100644 index 000000000..cc1ae30f9 --- /dev/null +++ b/src/hooks/mypage/useAddProfileFlow.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { editProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; + +function useAddProfileFlow() { + const [step, setStep] = useState(1); + const [selectedClubIds, setSelectedClubIds] = useState(['1', '2']); + const stepOneSchema = editProfileSchema.pick({ name: true, bio: true }); + + const { + control, + reset, + trigger, + formState: { errors }, + } = useForm>({ + resolver: zodResolver(stepOneSchema), + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + name: '', + bio: '', + }, + }); + + const resetFlow = () => { + setStep(1); + reset({ name: '', bio: '' }); + setSelectedClubIds(['1', '2']); + }; + + const handleToggleClub = (clubId: string) => { + setSelectedClubIds((prev) => + prev.includes(clubId) ? prev.filter((id) => id !== clubId) : [...prev, clubId], + ); + }; + + const handleNext = async () => { + const isValid = await trigger(); + if (!isValid) return false; + setStep(2); + return true; + }; + + return { + step, + setStep, + selectedClubIds, + control, + errors, + resetFlow, + handleToggleClub, + handleNext, + }; +} + +export { useAddProfileFlow }; diff --git a/src/hooks/mypage/useEditProfileForm.ts b/src/hooks/mypage/useEditProfileForm.ts new file mode 100644 index 000000000..7a609f994 --- /dev/null +++ b/src/hooks/mypage/useEditProfileForm.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { editProfileSchema, type EditProfileFormData } from '@/lib/schemas/editProfile'; + +interface EditProfileFormValuesSource { + name: string; + description: string; +} + +function useEditProfileForm(profile: EditProfileFormValuesSource | null, enabled = true) { + const editProfileFormSchema = editProfileSchema.pick({ name: true, bio: true }); + + const form = useForm>({ + resolver: zodResolver(editProfileFormSchema), + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + name: profile?.name ?? '', + bio: profile?.description ?? '', + }, + }); + + const { control, reset } = form; + const name = useWatch({ control, name: 'name' }) ?? ''; + + useEffect(() => { + if (!enabled || !profile) return; + + reset({ + name: profile.name, + bio: profile.description, + }); + }, [enabled, profile, reset]); + + const resetToProfile = () => { + if (!profile) return; + + reset({ + name: profile.name, + bio: profile.description, + }); + }; + + return { + ...form, + name, + resetToProfile, + }; +} + +export { useEditProfileForm, type EditProfileFormValuesSource }; diff --git a/src/hooks/mypage/useImagePreview.ts b/src/hooks/mypage/useImagePreview.ts new file mode 100644 index 000000000..eba1dd76c --- /dev/null +++ b/src/hooks/mypage/useImagePreview.ts @@ -0,0 +1,64 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface UseImagePreviewOptions { + initialImageUrl?: string; + onFileChange?: (file: File) => void; + onResetImage?: () => void; +} + +function useImagePreview({ initialImageUrl, onFileChange, onResetImage }: UseImagePreviewOptions) { + const fileInputRef = useRef(null); + const previewUrlRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [isReset, setIsReset] = useState(false); + + useEffect(() => { + return () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + } + }; + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + } + + const url = URL.createObjectURL(file); + previewUrlRef.current = url; + setPreviewUrl(url); + setIsReset(false); + onFileChange?.(file); + e.target.value = ''; + }; + + const handleReset = () => { + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + + setPreviewUrl(null); + setIsReset(true); + onResetImage?.(); + }; + + const displayUrl = isReset ? null : (previewUrl ?? initialImageUrl ?? null); + const isPreview = !!previewUrl && !isReset; + + return { + fileInputRef, + displayUrl, + isPreview, + handleChange, + handleReset, + }; +} + +export { useImagePreview, type UseImagePreviewOptions }; diff --git a/src/hooks/useResponsiveGridColumns.ts b/src/hooks/useResponsiveGridColumns.ts new file mode 100644 index 000000000..4477110c0 --- /dev/null +++ b/src/hooks/useResponsiveGridColumns.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface UseResponsiveGridColumnsOptions { + itemCount: number; + minColumnWidth: number; + gap: number; +} + +function useResponsiveGridColumns({ + itemCount, + minColumnWidth, + gap, +}: UseResponsiveGridColumnsOptions) { + const containerRef = useRef(null); + const [columnCount, setColumnCount] = useState(1); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const measureColumnCount = () => { + const containerWidth = element.clientWidth; + const nextColumnCount = Math.max( + 1, + Math.min(itemCount, Math.floor((containerWidth + gap) / (minColumnWidth + gap))), + ); + + setColumnCount(nextColumnCount); + }; + + measureColumnCount(); + + const resizeObserver = new ResizeObserver(measureColumnCount); + resizeObserver.observe(element); + + return () => resizeObserver.disconnect(); + }, [gap, itemCount, minColumnWidth]); + + return { + containerRef, + columnCount, + isSingleColumn: columnCount <= 1, + }; +} + +export { useResponsiveGridColumns, type UseResponsiveGridColumnsOptions }; diff --git a/src/stores/theme-store.ts b/src/stores/theme-store.ts index ce44e8bdb..2790205e5 100644 --- a/src/stores/theme-store.ts +++ b/src/stores/theme-store.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; - -type ThemeMode = 'auto' | 'light' | 'dark'; +import type { ThemeMode } from '@/types/theme'; interface ThemeStore { mode: ThemeMode; diff --git a/src/types/mypage.ts b/src/types/mypage.ts index 1d53f5e85..df1fc744e 100644 --- a/src/types/mypage.ts +++ b/src/types/mypage.ts @@ -17,6 +17,16 @@ export interface MyMember { bio: string | null; } +export interface ProfileData { + name: string; + bio?: string; + profileImageUrl?: string; + tel?: string; + email?: string; + school?: string; + department?: string; +} + export interface ClubDto { id: string; name: string; diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 000000000..a6ef4e91d --- /dev/null +++ b/src/types/theme.ts @@ -0,0 +1 @@ +export type ThemeMode = 'auto' | 'light' | 'dark'; diff --git a/src/utils/shared/date.ts b/src/utils/shared/date.ts index c2adb8158..60f820e32 100644 --- a/src/utils/shared/date.ts +++ b/src/utils/shared/date.ts @@ -108,3 +108,23 @@ export function formatTimeDisplay(timeStr: string): string { const displayHour = h % 12 || 12; return `${period} ${displayHour}:${String(m).padStart(2, '0')}`; } + +export function formatSessionDateParts(start: string): { + day: string; + weekday: string; + timeLabel: string; +} { + const date = new Date(start); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const period = hours < 12 ? '오전' : '오후'; + const displayHour = hours % 12 || 12; + + return { + day: String(day), + weekday: DAY_META[date.getDay()].en.toUpperCase(), + timeLabel: `${month}월 ${day}일 ${period} ${displayHour}:${minutes}`, + }; +}