refactor: Critical 코드 품질 개선
- useAdminAuth: useRef로 logout 함수 안정화하여 무한 루프 방지 - useAdminAuth/useRedirectIfAuthenticated: queryKey 충돌 해결 - useAuthStore: 미사용 getToken, checkAuth 메서드 제거 - useUIStore: 미사용 confirmDialog 관련 코드 제거 - 카드 컴포넌트 React.memo 적용 (PC/Mobile ScheduleCard, BirthdayCard 등) - 접근성(a11y) 개선: aria-label, role 속성 추가 - Toast: role="alert", aria-live="polite" - Lightbox: role="dialog", aria-modal, aria-label - Calendar: 버튼 aria-label, aria-pressed, aria-expanded - LightboxIndicator: aria-label, aria-current Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
72b3800ce7
commit
d9b8e67b9a
14 changed files with 83 additions and 70 deletions
|
|
@ -84,6 +84,9 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && images.length > 0 && (
|
{isOpen && images.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="이미지 뷰어"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
|
@ -96,22 +99,24 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
||||||
{/* 닫기 버튼 */}
|
{/* 닫기 버튼 */}
|
||||||
<button
|
<button
|
||||||
|
aria-label="닫기"
|
||||||
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X size={32} />
|
<X size={32} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
{/* 이전 버튼 */}
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<button
|
<button
|
||||||
|
aria-label="이전 이미지"
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goToPrev();
|
goToPrev();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={48} />
|
<ChevronLeft size={48} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -142,13 +147,14 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
{/* 다음 버튼 */}
|
{/* 다음 버튼 */}
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<button
|
<button
|
||||||
|
aria-label="다음 이미지"
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goToNext();
|
goToNext();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRight size={48} />
|
<ChevronRight size={48} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ const LightboxIndicator = memo(function LightboxIndicator({
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
|
aria-label={`이미지 ${i + 1}/${count}`}
|
||||||
|
aria-current={i === currentIndex ? 'true' : undefined}
|
||||||
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||||
i === currentIndex
|
i === currentIndex
|
||||||
? 'w-3 h-3 bg-white'
|
? 'w-3 h-3 bg-white'
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ function Toast({ toast, onClose }) {
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{toast && (
|
{toast && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 50 }}
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { dayjs, decodeHtmlEntities } from '@/utils';
|
import { dayjs, decodeHtmlEntities } from '@/utils';
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@ import { dayjs, decodeHtmlEntities } from '@/utils';
|
||||||
* @param {number} delay - 애니메이션 딜레이 (초)
|
* @param {number} delay - 애니메이션 딜레이 (초)
|
||||||
* @param {function} onClick - 클릭 핸들러
|
* @param {function} onClick - 클릭 핸들러
|
||||||
*/
|
*/
|
||||||
function BirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
|
const BirthdayCard = memo(function BirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
|
||||||
const scheduleDate = dayjs(schedule.date);
|
const scheduleDate = dayjs(schedule.date);
|
||||||
const formatted = {
|
const formatted = {
|
||||||
year: scheduleDate.year(),
|
year: scheduleDate.year(),
|
||||||
|
|
@ -79,6 +80,6 @@ function BirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
|
||||||
{CardContent}
|
{CardContent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default BirthdayCard;
|
export default BirthdayCard;
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,13 @@ function Calendar({
|
||||||
const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : [];
|
const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button key={index} onClick={() => onSelectDate(item.date)} className="flex flex-col items-center py-2">
|
<button
|
||||||
|
key={index}
|
||||||
|
aria-label={`${item.date.getMonth() + 1}월 ${item.day}일${isToday(item.date) ? ' (오늘)' : ''}${daySchedules.length > 0 ? `, 일정 ${daySchedules.length}개` : ''}`}
|
||||||
|
aria-pressed={isSelected(item.date)}
|
||||||
|
onClick={() => onSelectDate(item.date)}
|
||||||
|
className="flex flex-col items-center py-2"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all ${
|
className={`w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all ${
|
||||||
!item.isCurrentMonth
|
!item.isCurrentMonth
|
||||||
|
|
@ -259,19 +265,20 @@ function Calendar({
|
||||||
{/* 년도 범위 헤더 */}
|
{/* 년도 범위 헤더 */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<button
|
<button
|
||||||
|
aria-label="이전 연도 범위"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))
|
canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))
|
||||||
}
|
}
|
||||||
disabled={!canGoPrevYearRange}
|
disabled={!canGoPrevYearRange}
|
||||||
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
|
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-semibold text-sm">
|
<span className="font-semibold text-sm">
|
||||||
{yearRangeStart} - {yearRangeStart + 11}
|
{yearRangeStart} - {yearRangeStart + 11}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => setYearRangeStart(yearRangeStart + 12)} className="p-1">
|
<button aria-label="다음 연도 범위" onClick={() => setYearRangeStart(yearRangeStart + 12)} className="p-1">
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,6 +290,8 @@ function Calendar({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={y}
|
key={y}
|
||||||
|
aria-label={`${y}년 선택`}
|
||||||
|
aria-pressed={y === year}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newDate = new Date(viewDate);
|
const newDate = new Date(viewDate);
|
||||||
newDate.setFullYear(y);
|
newDate.setFullYear(y);
|
||||||
|
|
@ -311,6 +320,8 @@ function Calendar({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
|
aria-label={`${m}월 선택`}
|
||||||
|
aria-pressed={m === month + 1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newDate = new Date(year, m - 1, 1);
|
const newDate = new Date(year, m - 1, 1);
|
||||||
setViewDate(newDate);
|
setViewDate(newDate);
|
||||||
|
|
@ -342,21 +353,24 @@ function Calendar({
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
|
aria-label="이전 달"
|
||||||
onClick={() => changeMonth(-1)}
|
onClick={() => changeMonth(-1)}
|
||||||
disabled={!canGoPrevMonth}
|
disabled={!canGoPrevMonth}
|
||||||
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="년/월 선택"
|
||||||
|
aria-expanded={showYearMonth}
|
||||||
onClick={() => setShowYearMonth(true)}
|
onClick={() => setShowYearMonth(true)}
|
||||||
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
|
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{year}년 {month + 1}월
|
{year}년 {month + 1}월
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => changeMonth(1)} className="p-1">
|
<button aria-label="다음 달" onClick={() => changeMonth(1)} className="p-1">
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -367,6 +381,7 @@ function Calendar({
|
||||||
{/* 오늘 버튼 */}
|
{/* 오늘 버튼 */}
|
||||||
<div className="mt-3 flex justify-center">
|
<div className="mt-3 flex justify-center">
|
||||||
<button
|
<button
|
||||||
|
aria-label="오늘 날짜로 이동"
|
||||||
onClick={() => onSelectDate(new Date())}
|
onClick={() => onSelectDate(new Date())}
|
||||||
className="text-xs text-primary font-medium px-4 py-1.5 bg-primary/10 rounded-full"
|
className="text-xs text-primary font-medium px-4 py-1.5 bg-primary/10 rounded-full"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
|
|
@ -6,7 +7,7 @@ import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime
|
||||||
* 홈 페이지의 다가오는 일정 섹션에서 사용
|
* 홈 페이지의 다가오는 일정 섹션에서 사용
|
||||||
* 간결한 레이아웃
|
* 간결한 레이아웃
|
||||||
*/
|
*/
|
||||||
function ScheduleCard({ schedule, onClick, className = '' }) {
|
const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const currentYear = today.getFullYear();
|
const currentYear = today.getFullYear();
|
||||||
|
|
@ -94,6 +95,6 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default ScheduleCard;
|
export default ScheduleCard;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Clock, Link2 } from 'lucide-react';
|
import { Clock, Link2 } from 'lucide-react';
|
||||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
@ -7,7 +8,7 @@ import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime
|
||||||
* 스케줄 페이지에서 날짜별 일정 목록에 사용
|
* 스케줄 페이지에서 날짜별 일정 목록에 사용
|
||||||
* 날짜가 이미 헤더에 표시되므로 날짜 없이 표시
|
* 날짜가 이미 헤더에 표시되므로 날짜 없이 표시
|
||||||
*/
|
*/
|
||||||
function ScheduleListCard({
|
const ScheduleListCard = memo(function ScheduleListCard({
|
||||||
schedule,
|
schedule,
|
||||||
onClick,
|
onClick,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
|
|
@ -81,6 +82,6 @@ function ScheduleListCard({
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default ScheduleListCard;
|
export default ScheduleListCard;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Clock, Link2 } from 'lucide-react';
|
import { Clock, Link2 } from 'lucide-react';
|
||||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
@ -7,7 +8,7 @@ import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime
|
||||||
* 스케줄 페이지의 검색 결과에서 사용
|
* 스케줄 페이지의 검색 결과에서 사용
|
||||||
* 날짜를 왼쪽에 표시하는 레이아웃
|
* 날짜를 왼쪽에 표시하는 레이아웃
|
||||||
*/
|
*/
|
||||||
function ScheduleSearchCard({
|
const ScheduleSearchCard = memo(function ScheduleSearchCard({
|
||||||
schedule,
|
schedule,
|
||||||
onClick,
|
onClick,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
|
|
@ -110,6 +111,6 @@ function ScheduleSearchCard({
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default ScheduleSearchCard;
|
export default ScheduleSearchCard;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { dayjs } from '@/utils';
|
import { dayjs } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC용 생일 카드 컴포넌트
|
* PC용 생일 카드 컴포넌트
|
||||||
*/
|
*/
|
||||||
function BirthdayCard({ schedule, showYear = false, onClick }) {
|
const BirthdayCard = memo(function BirthdayCard({ schedule, showYear = false, onClick }) {
|
||||||
const scheduleDate = dayjs(schedule.date);
|
const scheduleDate = dayjs(schedule.date);
|
||||||
const formatted = {
|
const formatted = {
|
||||||
year: scheduleDate.year(),
|
year: scheduleDate.year(),
|
||||||
|
|
@ -55,6 +56,6 @@ function BirthdayCard({ schedule, showYear = false, onClick }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default BirthdayCard;
|
export default BirthdayCard;
|
||||||
|
|
|
||||||
|
|
@ -137,21 +137,24 @@ function Calendar({
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<button
|
<button
|
||||||
|
aria-label="이전 달"
|
||||||
onClick={prevMonth}
|
onClick={prevMonth}
|
||||||
disabled={!canGoPrevMonth}
|
disabled={!canGoPrevMonth}
|
||||||
className={`p-2 rounded-full transition-colors ${canGoPrevMonth ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
className={`p-2 rounded-full transition-colors ${canGoPrevMonth ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="년/월 선택"
|
||||||
|
aria-expanded={showYearMonthPicker}
|
||||||
onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}
|
onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}
|
||||||
className="flex items-center gap-1 text-xl font-bold hover:text-primary transition-colors"
|
className="flex items-center gap-1 text-xl font-bold hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<span>{year}년 {month + 1}월</span>
|
<span>{year}년 {month + 1}월</span>
|
||||||
<ChevronDown size={20} className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
|
<ChevronDown size={20} aria-hidden="true" className={`transition-transform ${showYearMonthPicker ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={nextMonth} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button aria-label="다음 달" onClick={nextMonth} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<ChevronRight size={24} />
|
<ChevronRight size={24} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -166,17 +169,18 @@ function Calendar({
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
|
aria-label="이전 연도 범위"
|
||||||
onClick={prevYearRange}
|
onClick={prevYearRange}
|
||||||
disabled={!canGoPrevYearRange}
|
disabled={!canGoPrevYearRange}
|
||||||
className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
className={`p-1.5 rounded-lg transition-colors ${canGoPrevYearRange ? 'hover:bg-gray-100' : 'opacity-30'}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
<ChevronLeft size={20} aria-hidden="true" className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900">
|
||||||
{yearRange[0]} - {yearRange[yearRange.length - 1]}
|
{yearRange[0]} - {yearRange[yearRange.length - 1]}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={nextYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
|
<button aria-label="다음 연도 범위" onClick={nextYearRange} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
<ChevronRight size={20} className="text-gray-600" />
|
<ChevronRight size={20} aria-hidden="true" className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -186,6 +190,8 @@ function Calendar({
|
||||||
{yearRange.map((y) => (
|
{yearRange.map((y) => (
|
||||||
<button
|
<button
|
||||||
key={y}
|
key={y}
|
||||||
|
aria-label={`${y}년 선택`}
|
||||||
|
aria-pressed={year === y}
|
||||||
onClick={() => selectYear(y)}
|
onClick={() => selectYear(y)}
|
||||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||||
year === y
|
year === y
|
||||||
|
|
@ -206,6 +212,8 @@ function Calendar({
|
||||||
{MONTHS.map((m, i) => (
|
{MONTHS.map((m, i) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
|
aria-label={`${m} 선택`}
|
||||||
|
aria-pressed={month === i}
|
||||||
onClick={() => selectMonth(i)}
|
onClick={() => selectMonth(i)}
|
||||||
className={`py-2 text-sm rounded-lg transition-colors ${
|
className={`py-2 text-sm rounded-lg transition-colors ${
|
||||||
month === i
|
month === i
|
||||||
|
|
@ -269,6 +277,8 @@ function Calendar({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={day}
|
key={day}
|
||||||
|
aria-label={`${month + 1}월 ${day}일${isToday ? ' (오늘)' : ''}${daySchedules.length > 0 ? `, 일정 ${daySchedules.length}개` : ''}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
onClick={() => selectDate(day)}
|
onClick={() => selectDate(day)}
|
||||||
className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100
|
className={`aspect-square flex flex-col items-center justify-center rounded-full text-base font-medium transition-all relative hover:bg-gray-100
|
||||||
${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
|
${isSelected ? 'bg-primary text-white shadow-lg hover:bg-primary' : ''}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react';
|
||||||
import { Clock, Tag, Link2 } from 'lucide-react';
|
import { Clock, Tag, Link2 } from 'lucide-react';
|
||||||
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime } from '@/utils';
|
||||||
|
|
||||||
|
|
@ -5,7 +6,7 @@ import { decodeHtmlEntities, getDisplayMembers, getCategoryInfo, getScheduleTime
|
||||||
* PC용 일정 카드 컴포넌트
|
* PC용 일정 카드 컴포넌트
|
||||||
* 홈, 스케줄 페이지에서 공통으로 사용
|
* 홈, 스케줄 페이지에서 공통으로 사용
|
||||||
*/
|
*/
|
||||||
function ScheduleCard({ schedule, onClick, className = '' }) {
|
const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
const scheduleDate = new Date(schedule.date);
|
const scheduleDate = new Date(schedule.date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const currentYear = today.getFullYear();
|
const currentYear = today.getFullYear();
|
||||||
|
|
@ -89,6 +90,6 @@ function ScheduleCard({ schedule, onClick, className = '' }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default ScheduleCard;
|
export default ScheduleCard;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '@/stores';
|
import { useAuthStore } from '@/stores';
|
||||||
|
|
@ -16,9 +16,13 @@ export function useAdminAuth(options = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { token, user, logout, isAuthenticated } = useAuthStore();
|
const { token, user, logout, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
// 토큰 검증 쿼리
|
// logout 함수를 ref로 안정화하여 무한 루프 방지
|
||||||
|
const logoutRef = useRef(logout);
|
||||||
|
logoutRef.current = logout;
|
||||||
|
|
||||||
|
// 토큰 검증 쿼리 - 고유 queryKey 사용
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['admin', 'auth'],
|
queryKey: ['admin', 'auth', 'verify'],
|
||||||
queryFn: authApi.verifyToken,
|
queryFn: authApi.verifyToken,
|
||||||
enabled: !!token,
|
enabled: !!token,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|
@ -29,10 +33,10 @@ export function useAdminAuth(options = {}) {
|
||||||
// 리다이렉트는 DOM 조작이므로 useEffect 사용 허용
|
// 리다이렉트는 DOM 조작이므로 useEffect 사용 허용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (required && (!token || isError)) {
|
if (required && (!token || isError)) {
|
||||||
logout();
|
logoutRef.current();
|
||||||
navigate(redirectTo);
|
navigate(redirectTo);
|
||||||
}
|
}
|
||||||
}, [token, isError, required, logout, navigate, redirectTo]);
|
}, [token, isError, required, navigate, redirectTo]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: data?.user || user,
|
user: data?.user || user,
|
||||||
|
|
@ -49,11 +53,11 @@ export function useAdminAuth(options = {}) {
|
||||||
*/
|
*/
|
||||||
export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') {
|
export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, token } = useAuthStore();
|
const { token } = useAuthStore();
|
||||||
|
|
||||||
// 토큰 검증
|
// 토큰 검증 - 고유 queryKey 사용
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'auth'],
|
queryKey: ['admin', 'auth', 'redirect-check'],
|
||||||
queryFn: authApi.verifyToken,
|
queryFn: authApi.verifyToken,
|
||||||
enabled: !!token,
|
enabled: !!token,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,6 @@ const useAuthStore = create(
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// 토큰 가져오기
|
|
||||||
getToken: () => get().token,
|
|
||||||
|
|
||||||
// 인증 여부 확인
|
|
||||||
checkAuth: () => {
|
|
||||||
const { token } = get();
|
|
||||||
return !!token;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|
|
||||||
|
|
@ -93,29 +93,6 @@ const useUIStore = create((set, get) => ({
|
||||||
// ===== 로딩 =====
|
// ===== 로딩 =====
|
||||||
globalLoading: false,
|
globalLoading: false,
|
||||||
setGlobalLoading: (value) => set({ globalLoading: value }),
|
setGlobalLoading: (value) => set({ globalLoading: value }),
|
||||||
|
|
||||||
// ===== 확인 다이얼로그 =====
|
|
||||||
confirmDialog: null,
|
|
||||||
|
|
||||||
showConfirm: (options) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
set({
|
|
||||||
confirmDialog: {
|
|
||||||
...options,
|
|
||||||
onConfirm: () => {
|
|
||||||
set({ confirmDialog: null });
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
set({ confirmDialog: null });
|
|
||||||
resolve(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closeConfirm: () => set({ confirmDialog: null }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useUIStore;
|
export default useUIStore;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue