diff --git a/src/App.tsx b/src/App.tsx index 77fb20a..14f592b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import LicensePage from './pages/legal/LicensePage'; import MarketingPolicyPage from './pages/legal/MarketingPolicyPage'; import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage'; import TermsPage from './pages/legal/TermsPage'; +import Timer from './pages/Timer'; import MyPage from './pages/User/MyPage'; import Profile from './pages/User/Profile'; @@ -46,6 +47,7 @@ function App() { } /> } /> + } /> }> } /> diff --git a/src/assets/svg/timer.svg b/src/assets/svg/timer.svg new file mode 100644 index 0000000..c392daf --- /dev/null +++ b/src/assets/svg/timer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/layout/BottomNav/index.tsx b/src/components/layout/BottomNav/index.tsx index 2b71a30..2c8ee95 100644 --- a/src/components/layout/BottomNav/index.tsx +++ b/src/components/layout/BottomNav/index.tsx @@ -1,9 +1,9 @@ import clsx from 'clsx'; import { useLocation, Link } from 'react-router-dom'; -import SearchIcon from '@/assets/svg/big-search.svg'; import HouseIcon from '@/assets/svg/house.svg'; import PeopleIcon from '@/assets/svg/people.svg'; import PersonIcon from '@/assets/svg/person.svg'; +import TimerIcon from '@/assets/svg/timer.svg'; function BottomNav() { const { pathname } = useLocation(); @@ -23,9 +23,9 @@ function BottomNav() { 총동연 - - - 모집 + + + 모집 diff --git a/src/components/layout/Header/index.tsx b/src/components/layout/Header/index.tsx index 326ee5c..3beb013 100644 --- a/src/components/layout/Header/index.tsx +++ b/src/components/layout/Header/index.tsx @@ -5,7 +5,7 @@ import useChat from '@/pages/Chat/hooks/useChat'; import { useMyInfo } from '@/pages/User/Profile/hooks/useMyInfo'; import { ROUTE_TITLES } from './routeTitles'; -const INFO_HEADER_LIST = ['/home', '/council']; +const INFO_HEADER_LIST = ['/home', '/council', '/timer']; function NotificationBell() { const { totalUnreadCount } = useChat(); diff --git a/src/pages/Timer/components/RankingItem.tsx b/src/pages/Timer/components/RankingItem.tsx new file mode 100644 index 0000000..8265057 --- /dev/null +++ b/src/pages/Timer/components/RankingItem.tsx @@ -0,0 +1,48 @@ +import clsx from 'clsx'; + +interface RankingItemType { + rank: number | null; + name: string; + school: string; + total: string; + today: string; + isMe?: boolean; +} + +interface RankingItemProps { + item: RankingItemType; +} + +const RankingItem = ({ item }: RankingItemProps) => ( +
+
+ {item.rank && item.rank <= 3 ? ( + + {item.rank} + + ) : ( + + {item.rank || (item.isMe ? '1290' : '')} + + )} +
+
+
+ {item.name} + {item.school && {item.school}} +
+
+ 누적 공부시간 : {item.total} | 오늘 공부시간 : {item.today} +
+
+
+); + +export default RankingItem; diff --git a/src/pages/Timer/components/TimerButton.tsx b/src/pages/Timer/components/TimerButton.tsx new file mode 100644 index 0000000..63c436e --- /dev/null +++ b/src/pages/Timer/components/TimerButton.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx'; +import { formatTime } from '@/utils/ts/time'; + +interface TimerButtonProps { + time: number; + isRunning: boolean; + onToggle: () => void; +} + +function TimerButton({ time, isRunning, onToggle }: TimerButtonProps) { + return ( + + ); +} + +export default TimerButton; diff --git a/src/pages/Timer/index.tsx b/src/pages/Timer/index.tsx new file mode 100644 index 0000000..e08fddc --- /dev/null +++ b/src/pages/Timer/index.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import clsx from 'clsx'; +import { useBottomSheet } from '@/utils/hooks/useBottomSheet'; +import { useTimer } from '@/utils/hooks/useTimer'; +import RankingItem from './components/RankingItem'; +import TimerButton from './components/TimerButton'; + +type TabType = '동아리' | '학년' | '개인'; + +function TimerPage() { + const { time, isRunning, toggle } = useTimer(); + const { position, isDragging, currentTranslate, sheetRef, handlers } = useBottomSheet(); + const [activeTab, setActiveTab] = useState('개인'); + + const rankingsData: Record< + TabType, + Array<{ + rank: number | null; + name: string; + school: string; + total: string; + today: string; + isMe?: boolean; + }> + > = { + 동아리: [ + { rank: 4, name: 'BCSD', school: '', total: '1245시간', today: '32시간', isMe: true }, + { rank: 1, name: 'KUT', school: '', total: '4000시간', today: '120시간' }, + { rank: 2, name: '한소리', school: '', total: '3900시간', today: '93시간' }, + { rank: 3, name: '비상', school: '', total: '3899시간', today: '101시간' }, + { rank: 5, name: 'KORA', school: '', total: '1100시간', today: '45시간' }, + { rank: 6, name: '밥버러지', school: '', total: '110시간', today: '43시간' }, + { rank: 7, name: '동아퀴', school: '', total: '11시간', today: '4시간' }, + ], + 학년: [ + { rank: 1, name: '1학년', school: '', total: '5000시간', today: '150시간' }, + { rank: 2, name: '2학년', school: '', total: '4500시간', today: '130시간' }, + { rank: 3, name: '3학년', school: '', total: '4000시간', today: '100시간', isMe: true }, + { rank: 4, name: '4학년', school: '', total: '3500시간', today: '80시간' }, + ], + 개인: [ + { rank: null, name: '이준영', school: '한국기술교육대학교', total: '50:42:49', today: '00:12:33', isMe: true }, + { rank: 1, name: '김혜준', school: '한국기술교육대학교', total: '118:42:49', today: '12:34:44' }, + { rank: 2, name: '공우진', school: '한국기술교육대학교', total: '112:41:49', today: '9:34:44' }, + { rank: 3, name: '홍길동', school: '한국기술교육대학교', total: '100:30:00', today: '8:00:00' }, + { rank: 4, name: '김철수', school: '한국기술교육대학교', total: '95:20:00', today: '7:30:00' }, + { rank: 5, name: '이영희', school: '한국기술교육대학교', total: '90:10:00', today: '6:00:00' }, + ], + }; + + const tabs: TabType[] = ['동아리', '학년', '개인']; + const rankings = rankingsData[activeTab]; + + const myRanking = rankings.find((item) => item.isMe); + const sortedRankings = rankings.slice().sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity)); + + return ( +
+ {isRunning &&
} +
+ +
+ +
+
+
+
+
+
랭킹
+ +
+ +
+ {tabs.map((tab) => ( + + ))} +
+ +
+ {myRanking && } + {sortedRankings.map((item, index) => ( + + ))} +
+
+
+ ); +} + +export default TimerPage; diff --git a/src/styles/theme.css b/src/styles/theme.css index 85241b5..ceda73a 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -29,6 +29,7 @@ --color-indigo-900: #020b16; /* Blue Scale */ + --color-blue-200: #8ec2fc; --color-blue-500: #59a7fc; /* ======================================== diff --git a/src/utils/hooks/useBottomSheet.ts b/src/utils/hooks/useBottomSheet.ts new file mode 100644 index 0000000..7eedc44 --- /dev/null +++ b/src/utils/hooks/useBottomSheet.ts @@ -0,0 +1,46 @@ +import { useRef, useState } from 'react'; + +type SheetPosition = 'half' | 'full'; + +export const useBottomSheet = (threshold = 30) => { + const [position, setPosition] = useState('half'); + const [isDragging, setIsDragging] = useState(false); + const [currentTranslate, setCurrentTranslate] = useState(0); + const startY = useRef(0); + const sheetRef = useRef(null); + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + startY.current = e.touches[0].clientY; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging) return; + const deltaY = e.touches[0].clientY - startY.current; + setCurrentTranslate(deltaY); + }; + + const handleTouchEnd = () => { + setIsDragging(false); + + if (position === 'half' && currentTranslate < -threshold) { + setPosition('full'); + } else if (position === 'full' && currentTranslate > threshold) { + setPosition('half'); + } + + setCurrentTranslate(0); + }; + + return { + position, + isDragging, + currentTranslate, + sheetRef, + handlers: { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + }, + }; +}; diff --git a/src/utils/hooks/useTimer.ts b/src/utils/hooks/useTimer.ts new file mode 100644 index 0000000..c419106 --- /dev/null +++ b/src/utils/hooks/useTimer.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from 'react'; + +export const useTimer = () => { + const [time, setTime] = useState(0); + const [isRunning, setIsRunning] = useState(false); + const wasRunningRef = useRef(false); + const intervalRef = useRef(null); + + useEffect(() => { + if (isRunning) { + intervalRef.current = setInterval(() => { + setTime((prevTime) => prevTime + 1); + }, 1000); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isRunning]); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden && isRunning) { + wasRunningRef.current = true; + setIsRunning(false); + } else if (!document.hidden && wasRunningRef.current) { + wasRunningRef.current = false; + setIsRunning(true); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [isRunning]); + + const toggle = () => setIsRunning((prev) => !prev); + const reset = () => setTime(0); + + return { time, isRunning, toggle, reset }; +}; diff --git a/src/utils/ts/time.ts b/src/utils/ts/time.ts new file mode 100644 index 0000000..3abfc66 --- /dev/null +++ b/src/utils/ts/time.ts @@ -0,0 +1,7 @@ +export const formatTime = (totalSeconds: number): string => { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return [hours, minutes, seconds].map((unit) => String(unit).padStart(2, '0')).join(':'); +};