refactor(mobile): Swiper 제거하고 터치 제스처 방식 CalendarPicker로 변경

This commit is contained in:
caadiq 2026-01-07 14:23:02 +09:00
parent 0750dded97
commit 767cbcaf5f
4 changed files with 234 additions and 49 deletions

View file

@ -12,6 +12,7 @@
"framer-motion": "^11.0.8", "framer-motion": "^11.0.8",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-calendar": "^6.0.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -1259,6 +1260,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@wojtekmaj/date-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-2.0.2.tgz",
"integrity": "sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/date-utils?sponsor=1"
}
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@ -1463,6 +1473,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -1717,6 +1736,18 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-user-locale": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-3.0.0.tgz",
"integrity": "sha512-iJfHSmdYV39UUBw7Jq6GJzeJxUr4U+S03qdhVuDsR9gCEnfbqLy9gYDJFBJQL1riqolFUKQvx36mEkp2iGgJ3g==",
"license": "MIT",
"dependencies": {
"memoize": "^10.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -1898,6 +1929,21 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/memoize": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/memoize/-/memoize-10.2.0.tgz",
"integrity": "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==",
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/memoize?sponsor=1"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -1922,6 +1968,18 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "11.18.1", "version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@ -2265,6 +2323,31 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-calendar": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-6.0.0.tgz",
"integrity": "sha512-6wqaki3Us0DNDjZDr0DYIzhSFprNoy4FdPT9Pjy5aD2hJJVjtJwmdMT9VmrTUo949nlk35BOxehThxX62RkuRQ==",
"license": "MIT",
"dependencies": {
"@wojtekmaj/date-utils": "^2.0.2",
"clsx": "^2.0.0",
"get-user-locale": "^3.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-calendar?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-colorful": { "node_modules/react-colorful": {
"version": "5.6.1", "version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
@ -2906,6 +2989,15 @@
} }
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -13,6 +13,7 @@
"framer-motion": "^11.0.8", "framer-motion": "^11.0.8",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-calendar": "^6.0.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View file

@ -98,3 +98,105 @@ body {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
/* Swiper autoHeight 지원 */
.swiper-slide {
height: auto !important;
}
.mobile-calendar-wrapper .react-calendar__navigation button:hover {
background-color: #f3f4f6;
border-radius: 0.5rem;
}
.mobile-calendar-wrapper .react-calendar__navigation__label {
font-weight: 600;
font-size: 0.875rem;
color: #374151;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays {
text-align: center;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday {
padding: 0.5rem 0;
}
.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday abbr {
text-decoration: none;
}
/* 일요일 (빨간색) */
.mobile-calendar-wrapper
.react-calendar__month-view__weekdays__weekday:first-child {
color: #f87171;
}
/* 토요일 (파란색) */
.mobile-calendar-wrapper
.react-calendar__month-view__weekdays__weekday:last-child {
color: #60a5fa;
}
.mobile-calendar-wrapper .react-calendar__tile {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.25rem;
background: none;
border: none;
font-size: 0.75rem;
color: #374151;
}
.mobile-calendar-wrapper .react-calendar__tile:hover {
background-color: #f3f4f6;
border-radius: 9999px;
}
.mobile-calendar-wrapper .react-calendar__tile abbr {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
}
/* 이웃 달 날짜 (흐리게) */
.mobile-calendar-wrapper
.react-calendar__month-view__days__day--neighboringMonth {
color: #d1d5db;
}
/* 일요일 */
.mobile-calendar-wrapper .react-calendar__tile.sunday abbr {
color: #ef4444;
}
/* 토요일 */
.mobile-calendar-wrapper .react-calendar__tile.saturday abbr {
color: #3b82f6;
}
/* 오늘 */
.mobile-calendar-wrapper .react-calendar__tile--now abbr {
background-color: #548360;
color: white;
font-weight: 700;
}
/* 선택된 날짜 */
.mobile-calendar-wrapper .react-calendar__tile--active abbr {
background-color: #548360;
color: white;
}
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:hover abbr,
.mobile-calendar-wrapper .react-calendar__tile--active:enabled:focus abbr {
background-color: #456e50;
}

View file

@ -3,8 +3,6 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react'; import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
// //
function MobileSchedule() { function MobileSchedule() {
@ -399,7 +397,10 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }
// //
function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate }) { function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate }) {
const [viewDate, setViewDate] = useState(new Date(selectedDate)); const [viewDate, setViewDate] = useState(new Date(selectedDate));
const swiperRef = useRef(null);
//
const touchStartX = useRef(0);
const touchEndX = useRef(0);
// //
const scheduleDates = useMemo(() => { const scheduleDates = useMemo(() => {
@ -490,33 +491,42 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, []); }, []);
// 3 (, , ) //
const slides = useMemo(() => { const currentMonthDays = useMemo(() => {
return [-1, 0, 1].map(offset => { return getCalendarDays(year, month);
const d = new Date(year, month + offset, 1);
return {
year: d.getFullYear(),
month: d.getMonth(),
days: getCalendarDays(d.getFullYear(), d.getMonth())
};
});
}, [year, month, getCalendarDays]); }, [year, month, getCalendarDays]);
// //
const handleSlideChange = (swiper) => { const handleTouchStart = (e) => {
const direction = swiper.activeIndex - 1; // -1, 0, 1 touchStartX.current = e.touches[0].clientX;
if (direction !== 0) { };
changeMonth(direction);
// ( ) const handleTouchMove = (e) => {
setTimeout(() => { touchEndX.current = e.touches[0].clientX;
swiper.slideTo(1, 0); };
}, 0);
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current;
const threshold = 50;
if (Math.abs(diff) > threshold) {
if (diff > 0) {
changeMonth(1);
} else {
changeMonth(-1);
}
} }
touchStartX.current = 0;
touchEndX.current = 0;
}; };
// //
const renderMonth = (slideData) => ( const renderMonth = (days) => (
<div> <div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* 요일 헤더 */} {/* 요일 헤더 */}
<div className="grid grid-cols-7 gap-1 mb-2"> <div className="grid grid-cols-7 gap-1 mb-2">
{['일', '월', '화', '수', '목', '금', '토'].map((day, i) => ( {['일', '월', '화', '수', '목', '금', '토'].map((day, i) => (
@ -533,7 +543,7 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
{/* 날짜 그리드 */} {/* 날짜 그리드 */}
<div className="grid grid-cols-7 gap-1"> <div className="grid grid-cols-7 gap-1">
{slideData.days.map((item, index) => { {days.map((item, index) => {
const dayOfWeek = index % 7; const dayOfWeek = index % 7;
const isSunday = dayOfWeek === 0; const isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6; const isSaturday = dayOfWeek === 6;
@ -671,9 +681,7 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
{/* 달력 헤더 */} {/* 달력 헤더 */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <button
onClick={() => { onClick={() => changeMonth(-1)}
swiperRef.current?.slidePrev();
}}
className="p-1" className="p-1"
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
@ -686,33 +694,15 @@ function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelec
<ChevronDown size={16} /> <ChevronDown size={16} />
</button> </button>
<button <button
onClick={() => { onClick={() => changeMonth(1)}
swiperRef.current?.slideNext();
}}
className="p-1" className="p-1"
> >
<ChevronRight size={18} /> <ChevronRight size={18} />
</button> </button>
</div> </div>
{/* Swiper 달력 */} {/* 달력 (터치 스와이프 지원) */}
<Swiper {renderMonth(currentMonthDays)}
onSwiper={(swiper) => { swiperRef.current = swiper; }}
initialSlide={1}
onSlideChangeTransitionEnd={handleSlideChange}
spaceBetween={20}
slidesPerView={1}
speed={200}
touchRatio={1}
resistance={true}
resistanceRatio={0.5}
>
{slides.map((slide, idx) => (
<SwiperSlide key={`${slide.year}-${slide.month}-${idx}`}>
{renderMonth(slide)}
</SwiperSlide>
))}
</Swiper>
{/* 오늘 버튼 */} {/* 오늘 버튼 */}
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">