feat(frontend): Phase 5 - 커스텀 훅 구현

- useMediaQuery, useIsMobile, useIsDesktop: 반응형 레이아웃
- useScheduleData, useCategories: 스케줄/카테고리 데이터 조회
- useScheduleSearch: 무한 스크롤 검색
- useScheduleFiltering, useCategoryCounts: 필터링 및 정렬
- useCalendar: 캘린더 로직 (월 이동, 날짜 선택)
- useAdminAuth: 토큰 검증 및 리다이렉트
- utils/schedule.js: 스케줄 유틸리티 함수 추가
- constants: SEARCH_LIMIT, MIN_YEAR, MONTH_NAMES 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 17:22:38 +09:00
parent fe067ca8c8
commit 27c41b0af0
12 changed files with 659 additions and 75 deletions

View file

@ -1,50 +1,37 @@
import { useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { isMobile } from "react-device-detect";
import { useQuery } from "@tanstack/react-query";
import { cn, getTodayKST, formatFullDate } from "@/utils"; import { cn, getTodayKST, formatFullDate } from "@/utils";
import { useAuthStore, useUIStore } from "@/stores"; import { useAuthStore, useUIStore } from "@/stores";
import { memberApi, scheduleApi } from "@/api"; import { useIsMobile, useCategories, useCalendar } from "@/hooks";
import { memberApi } from "@/api";
import { useQuery } from "@tanstack/react-query";
/** /**
* 프로미스나인 팬사이트 메인 * 프로미스나인 팬사이트 메인
* *
* Phase 4: API 계층 완료 * Phase 5: 커스텀 완료
* - api/client.js: 기본 fetch 클라이언트 (공개/인증) * - useMediaQuery, useIsMobile, useIsDesktop
* - api/schedules.js: 스케줄 API * - useScheduleData, useScheduleDetail, useCategories
* - api/albums.js: 앨범 API * - useScheduleSearch (무한 스크롤)
* - api/members.js: 멤버 API * - useScheduleFiltering, useCategoryCounts
* - api/auth.js: 인증 API * - useCalendar
* - useAdminAuth
*/ */
function App() { function App() {
const today = getTodayKST(); const today = getTodayKST();
const isMobile = useIsMobile();
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
const { showSuccess, showError, toasts } = useUIStore(); const { showSuccess, toasts } = useUIStore();
const [apiTest, setApiTest] = useState(null);
// React Query //
const { data: categories, isLoading: categoriesLoading } = useCategories();
const calendar = useCalendar();
// ( )
const { data: members, isLoading: membersLoading } = useQuery({ const { data: members, isLoading: membersLoading } = useQuery({
queryKey: ["members"], queryKey: ["members"],
queryFn: memberApi.getMembers, queryFn: memberApi.getMembers,
}); });
// React Query
const { data: categories, isLoading: categoriesLoading } = useQuery({
queryKey: ["categories"],
queryFn: scheduleApi.getCategories,
});
const handleTestApi = async () => {
try {
const data = await memberApi.getMembers();
setApiTest(`멤버 ${data.length}명 조회 성공!`);
showSuccess("API 호출 성공!");
} catch (error) {
setApiTest(`에러: ${error.message}`);
showError("API 호출 실패");
}
};
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@ -56,9 +43,9 @@ function App() {
<h1 className="text-2xl font-bold text-primary mb-2"> <h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring fromis_9 Frontend Refactoring
</h1> </h1>
<p className="text-gray-600">Phase 4 완료 - API 계층</p> <p className="text-gray-600">Phase 5 완료 - 커스텀 </p>
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}> <p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"} 디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile )
</p> </p>
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3"> <div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
@ -66,31 +53,12 @@ function App() {
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p> <p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
<div className="border-t pt-3"> <div className="border-t pt-3">
<p className="font-semibold mb-2">API 테스트 (React Query)</p> <p className="font-semibold mb-2">useCategories </p>
<div className="space-y-2">
<p> <p>
<strong>멤버:</strong>{" "} {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`}
{membersLoading ? "로딩 중..." : `${members?.length || 0}`}
</p>
{members && (
<div className="flex flex-wrap gap-1">
{members.map((m) => (
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
{m.name}
</span>
))}
</div>
)}
</div>
<div className="space-y-2 mt-3">
<p>
<strong>카테고리:</strong>{" "}
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}`}
</p> </p>
{categories && ( {categories && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1 mt-1">
{categories.map((c) => ( {categories.map((c) => (
<span <span
key={c.id} key={c.id}
@ -103,17 +71,49 @@ function App() {
</div> </div>
)} )}
</div> </div>
<div className="border-t pt-3">
<p className="font-semibold mb-2">useCalendar </p>
<p><strong>현재:</strong> {calendar.year} {calendar.monthName}</p>
<p><strong>선택:</strong> {calendar.selectedDate}</p>
<div className="flex gap-2 mt-2">
<button
onClick={calendar.goToPrevMonth}
disabled={!calendar.canGoPrevMonth}
className={cn(
"px-3 py-1 rounded text-xs",
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
)}
>
이전
</button>
<button
onClick={calendar.goToToday}
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
>
오늘
</button>
<button
onClick={calendar.goToNextMonth}
className="px-3 py-1 bg-primary text-white rounded text-xs"
>
다음
</button>
</div>
</div> </div>
<div className="border-t pt-3"> <div className="border-t pt-3">
<p className="font-semibold mb-2">직접 API 호출 테스트</p> <p className="font-semibold mb-2">멤버 데이터</p>
<button <p>{membersLoading ? "로딩 중..." : `${members?.length || 0}`}</p>
onClick={handleTestApi} {members && (
className="px-3 py-1 bg-primary text-white rounded text-xs" <div className="flex flex-wrap gap-1 mt-1">
> {members.map((m) => (
멤버 API 호출 <span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
</button> {m.name}
{apiTest && <p className="mt-2 text-xs text-gray-600">{apiTest}</p>} </span>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -43,5 +43,17 @@ export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
/** 기본 페이지 크기 */ /** 기본 페이지 크기 */
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
/** 검색 결과 페이지 크기 */
export const SEARCH_LIMIT = 20;
/** API 기본 URL */ /** API 기본 URL */
export const API_BASE_URL = '/api'; export const API_BASE_URL = '/api';
/** 캘린더 최소 년도 */
export const MIN_YEAR = 2017;
/** 월 이름 */
export const MONTH_NAMES = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월',
];

View file

@ -0,0 +1,26 @@
/**
* 통합 export
*/
// 미디어 쿼리
export { useMediaQuery, useIsMobile, useIsDesktop, useIsTablet } from './useMediaQuery';
// 스케줄 데이터
export {
useScheduleData,
useScheduleDetail,
useUpcomingSchedules,
useCategories,
} from './useScheduleData';
// 스케줄 검색
export { useScheduleSearch } from './useScheduleSearch';
// 스케줄 필터링
export { useScheduleFiltering, useCategoryCounts } from './useScheduleFiltering';
// 캘린더
export { useCalendar } from './useCalendar';
// 인증
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';

View file

@ -0,0 +1,72 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores';
import { authApi } from '@/api';
/**
* 어드민 인증
* 토큰 유효성 검증 미인증 리다이렉트
* @param {object} options - 옵션
* @param {string} options.redirectTo - 미인증 리다이렉트 경로 (기본: /admin)
* @param {boolean} options.required - 인증 필수 여부 (기본: true)
*/
export function useAdminAuth(options = {}) {
const { redirectTo = '/admin', required = true } = options;
const navigate = useNavigate();
const { token, user, logout, isAuthenticated } = useAuthStore();
// 토큰 검증 쿼리
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'auth'],
queryFn: authApi.verifyToken,
enabled: !!token,
retry: false,
staleTime: 1000 * 60 * 5, // 5분 캐시
});
// 토큰 없거나 검증 실패 시 처리
// 리다이렉트는 DOM 조작이므로 useEffect 사용 허용
useEffect(() => {
if (required && (!token || isError)) {
logout();
navigate(redirectTo);
}
}, [token, isError, required, logout, navigate, redirectTo]);
return {
user: data?.user || user,
isLoading: !token ? false : isLoading,
isAuthenticated: !!data?.valid || isAuthenticated,
isError,
};
}
/**
* 로그인 페이지에서 사용하는
* 이미 인증된 경우 리다이렉트
* @param {string} redirectTo - 인증된 경우 리다이렉트 경로
*/
export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') {
const navigate = useNavigate();
const { isAuthenticated, token } = useAuthStore();
// 토큰 검증
const { data, isLoading } = useQuery({
queryKey: ['admin', 'auth'],
queryFn: authApi.verifyToken,
enabled: !!token,
retry: false,
});
useEffect(() => {
if (data?.valid) {
navigate(redirectTo);
}
}, [data, navigate, redirectTo]);
return {
isLoading: !!token && isLoading,
isAuthenticated: !!data?.valid,
};
}

View file

@ -0,0 +1,104 @@
import { useState, useMemo, useCallback } from 'react';
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants';
import { getTodayKST } from '@/utils';
/**
* 캘린더
* 날짜 선택, 이동 캘린더 로직 제공
* @param {Date} initialDate - 초기 날짜
*/
export function useCalendar(initialDate = new Date()) {
const [currentDate, setCurrentDate] = useState(initialDate);
const [selectedDate, setSelectedDate] = useState(getTodayKST());
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 캘린더 데이터 계산
const calendarData = useMemo(() => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const prevMonthDays = new Date(year, month, 0).getDate();
return {
year,
month,
monthName: MONTH_NAMES[month],
firstDay,
daysInMonth,
prevMonthDays,
weekdays: WEEKDAYS,
};
}, [year, month]);
// 이전 월로 이동 가능 여부
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
// 선택 날짜 업데이트 헬퍼
const updateSelectedDate = useCallback((newDate) => {
const today = new Date();
if (
newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()
) {
setSelectedDate(getTodayKST());
} else {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay);
}
}, []);
// 이전 월로 이동
const goToPrevMonth = useCallback(() => {
if (!canGoPrevMonth) return;
const newDate = new Date(year, month - 1, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
}, [year, month, canGoPrevMonth, updateSelectedDate]);
// 다음 월로 이동
const goToNextMonth = useCallback(() => {
const newDate = new Date(year, month + 1, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
}, [year, month, updateSelectedDate]);
// 특정 월로 이동
const goToMonth = useCallback(
(newYear, newMonth) => {
const newDate = new Date(newYear, newMonth, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
},
[updateSelectedDate]
);
// 오늘로 이동
const goToToday = useCallback(() => {
const today = new Date();
setCurrentDate(today);
setSelectedDate(getTodayKST());
}, []);
// 날짜 선택
const selectDate = useCallback(
(day) => {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
setSelectedDate(dateStr);
},
[year, month]
);
return {
...calendarData,
currentDate,
selectedDate,
canGoPrevMonth,
goToPrevMonth,
goToNextMonth,
goToMonth,
goToToday,
selectDate,
setSelectedDate,
};
}

View file

@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
/**
* 미디어 쿼리
* @param {string} query - CSS 미디어 쿼리 문자열
* @returns {boolean} 쿼리 매칭 여부
*/
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
// DOM 이벤트 리스너이므로 useEffect 사용 허용
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
/**
* 모바일 여부 확인
*/
export const useIsMobile = () => useMediaQuery('(max-width: 768px)');
/**
* 데스크탑 여부 확인
*/
export const useIsDesktop = () => useMediaQuery('(min-width: 769px)');
/**
* 태블릿 여부 확인
*/
export const useIsTablet = () => useMediaQuery('(min-width: 769px) and (max-width: 1024px)');

View file

@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { scheduleApi } from '@/api';
/**
* 스케줄 목록 조회 (월별)
* @param {number} year - 년도
* @param {number} month - (1-12)
*/
export function useScheduleData(year, month) {
return useQuery({
queryKey: ['schedules', year, month],
queryFn: () => scheduleApi.getSchedules(year, month),
enabled: !!year && !!month,
});
}
/**
* 스케줄 상세 조회
* @param {number} id - 스케줄 ID
*/
export function useScheduleDetail(id) {
return useQuery({
queryKey: ['schedule', id],
queryFn: () => scheduleApi.getSchedule(id),
enabled: !!id,
});
}
/**
* 다가오는 스케줄 조회
* @param {number} limit - 조회 개수
*/
export function useUpcomingSchedules(limit = 3) {
return useQuery({
queryKey: ['schedules', 'upcoming', limit],
queryFn: () => scheduleApi.getUpcomingSchedules(limit),
});
}
/**
* 카테고리 목록 조회
*/
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: scheduleApi.getCategories,
staleTime: 1000 * 60 * 10, // 10분 캐시
});
}

View file

@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { formatDate } from '@/utils';
import { getCategoryId, getScheduleDate, isBirthdaySchedule } from '@/utils/schedule';
/**
* 스케줄 필터링
* @param {Array} schedules - 스케줄 배열
* @param {object} options - 필터 옵션
* @param {number[]} options.categoryIds - 선택된 카테고리 ID 배열
* @param {string} options.selectedDate - 선택된 날짜 (YYYY-MM-DD)
* @param {boolean} options.birthdayFirst - 생일 우선 정렬 여부
* @returns {Array} 필터링된 스케줄 배열
*/
export function useScheduleFiltering(schedules, options = {}) {
const {
categoryIds = [],
selectedDate = null,
birthdayFirst = true,
} = options;
return useMemo(() => {
if (!schedules || schedules.length === 0) return [];
let result = schedules.filter((schedule) => {
// 카테고리 필터
if (categoryIds.length > 0) {
const catId = getCategoryId(schedule);
if (!categoryIds.includes(catId)) return false;
}
// 날짜 필터
if (selectedDate) {
const scheduleDate = getScheduleDate(schedule);
if (scheduleDate !== selectedDate) return false;
}
return true;
});
// 생일 우선 정렬
if (birthdayFirst) {
result = [...result].sort((a, b) => {
const aIsBirthday = isBirthdaySchedule(a);
const bIsBirthday = isBirthdaySchedule(b);
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
return 0;
});
}
return result;
}, [schedules, categoryIds, selectedDate, birthdayFirst]);
}
/**
* 카테고리별 개수 계산
* @param {Array} schedules - 스케줄 배열
* @returns {object} 카테고리 ID별 개수 객체
*/
export function useCategoryCounts(schedules) {
return useMemo(() => {
if (!schedules || schedules.length === 0) return {};
const counts = {};
for (const schedule of schedules) {
const categoryId = getCategoryId(schedule);
counts[categoryId] = (counts[categoryId] || 0) + 1;
}
return counts;
}, [schedules]);
}

View file

@ -0,0 +1,75 @@
import { useState, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchApi } from '@/api';
import { SEARCH_LIMIT } from '@/constants';
/**
* 스케줄 검색
* 무한 스크롤, 검색어 추천 지원
*/
export function useScheduleSearch() {
const [searchInput, setSearchInput] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [isSearchMode, setIsSearchMode] = useState(false);
// 검색 결과 (무한 스크롤)
const searchQuery = useInfiniteQuery({
queryKey: ['scheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0, signal }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`,
{ signal }
);
if (!response.ok) throw new Error('Search failed');
return response.json();
},
getNextPageParam: (lastPage) => {
return lastPage.hasMore
? lastPage.offset + lastPage.schedules.length
: undefined;
},
enabled: !!searchTerm && isSearchMode,
});
// 검색 결과 평탄화
const searchResults = useMemo(() => {
return searchQuery.data?.pages?.flatMap((page) => page.schedules) || [];
}, [searchQuery.data]);
const searchTotal = searchQuery.data?.pages?.[0]?.total || 0;
// 검색 실행
const executeSearch = () => {
if (searchInput.trim()) {
setSearchTerm(searchInput.trim());
setIsSearchMode(true);
}
};
// 검색 초기화
const clearSearch = () => {
setSearchInput('');
setSearchTerm('');
setIsSearchMode(false);
};
return {
// 상태
searchInput,
searchTerm,
isSearchMode,
searchResults,
searchTotal,
isLoading: searchQuery.isLoading,
isFetching: searchQuery.isFetching,
hasNextPage: searchQuery.hasNextPage,
isFetchingNextPage: searchQuery.isFetchingNextPage,
// 액션
setSearchInput,
setIsSearchMode,
executeSearch,
clearSearch,
fetchNextPage: searchQuery.fetchNextPage,
};
}

View file

@ -32,3 +32,15 @@ export {
formatDuration, formatDuration,
truncateText, truncateText,
} from './format'; } from './format';
// 스케줄 관련
export {
getCategoryId,
getCategoryInfo,
getScheduleDate,
getScheduleTime,
getMemberList,
isBirthdaySchedule,
groupSchedulesByDate,
countByCategory,
} from './schedule';

View file

@ -0,0 +1,122 @@
/**
* 스케줄 관련 유틸리티 함수
*/
/**
* 스케줄에서 카테고리 ID 추출
* 검색 결과와 일반 데이터의 형식 차이를 처리
* @param {object} schedule - 스케줄 객체
* @returns {number|null} 카테고리 ID
*/
export function getCategoryId(schedule) {
return schedule.category?.id ?? schedule.category_id ?? schedule.categoryId ?? null;
}
/**
* 스케줄에서 카테고리 정보 추출
* @param {object} schedule - 스케줄 객체
* @returns {{ id: number, name: string, color: string }}
*/
export function getCategoryInfo(schedule) {
return {
id: getCategoryId(schedule),
name: schedule.category?.name ?? schedule.category_name ?? schedule.categoryName ?? '미분류',
color: schedule.category?.color ?? schedule.category_color ?? schedule.categoryColor ?? '#9CA3AF',
};
}
/**
* 스케줄에서 날짜 추출
* datetime 형식과 date 형식 모두 처리
* @param {object} schedule - 스케줄 객체
* @returns {string} YYYY-MM-DD 형식 날짜
*/
export function getScheduleDate(schedule) {
const datetime = schedule.datetime || schedule.date;
if (!datetime) return '';
return datetime.split(' ')[0].split('T')[0];
}
/**
* 스케줄에서 시간 추출
* @param {object} schedule - 스케줄 객체
* @returns {string|null} HH:mm 형식 시간 또는 null
*/
export function getScheduleTime(schedule) {
if (schedule.time) {
return schedule.time.slice(0, 5);
}
const datetime = schedule.datetime;
if (datetime && (datetime.includes(' ') || datetime.includes('T'))) {
const timePart = datetime.includes(' ')
? datetime.split(' ')[1]
: datetime.split('T')[1];
return timePart?.slice(0, 5) || null;
}
return null;
}
/**
* 스케줄에서 멤버 목록 추출
* 다양한 형식 처리 (문자열 배열, 객체 배열)
* @param {object} schedule - 스케줄 객체
* @returns {Array<{ id: number, name: string }>}
*/
export function getMemberList(schedule) {
const members = schedule.members || [];
if (members.length === 0) return [];
// 문자열 배열인 경우 (검색 결과)
if (typeof members[0] === 'string') {
return members.map((name, idx) => ({ id: idx, name }));
}
// 객체 배열인 경우
return members;
}
/**
* 생일 스케줄인지 확인
* @param {object} schedule - 스케줄 객체
* @returns {boolean}
*/
export function isBirthdaySchedule(schedule) {
const title = schedule.title || '';
return title.includes('생일') || title.includes('birthday');
}
/**
* 날짜별로 스케줄 그룹화
* @param {Array} schedules - 스케줄 배열
* @returns {Map<string, Array>} 날짜별 그룹화된
*/
export function groupSchedulesByDate(schedules) {
const groups = new Map();
for (const schedule of schedules) {
const date = getScheduleDate(schedule);
if (!groups.has(date)) {
groups.set(date, []);
}
groups.get(date).push(schedule);
}
return groups;
}
/**
* 카테고리별 스케줄 계산
* @param {Array} schedules - 스케줄 배열
* @returns {Map<number, number>} 카테고리 ID별 개수
*/
export function countByCategory(schedules) {
const counts = new Map();
for (const schedule of schedules) {
const categoryId = getCategoryId(schedule);
counts.set(categoryId, (counts.get(categoryId) || 0) + 1);
}
return counts;
}