-
-
+
);
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}`,
+ };
+}