Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -46,6 +47,7 @@ function App() {
<Route index element={<CouncilDetail />} />
<Route path="notice/:noticeId" element={<CouncilNotice />} />
</Route>
<Route path="timer" element={<Timer />} />
</Route>
<Route element={<Layout />}>
<Route path="profile" element={<Profile />} />
Expand Down
3 changes: 3 additions & 0 deletions src/assets/svg/timer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions src/components/layout/BottomNav/index.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -23,9 +23,9 @@ function BottomNav() {
<span className={clsx(pathname.startsWith('/council') ? 'text-primary' : 'text-indigo-100')}>총동연</span>
</Link>

<Link to="/clubs" className="flex flex-col items-center">
<SearchIcon className={clsx('h-5 w-5', pathname.startsWith('/clubs') ? 'text-primary' : 'text-indigo-100')} />
<span className={clsx(pathname.startsWith('/clubs') ? 'text-primary' : 'text-indigo-100')}>모집</span>
<Link to="/timer" className="flex flex-col items-center">
<TimerIcon className={clsx('h-5 w-5', pathname.startsWith('/timer') ? 'text-primary' : 'text-indigo-100')} />
<span className={clsx(pathname.startsWith('/timer') ? 'text-primary' : 'text-indigo-100')}>모집</span>
</Link>

<Link to="/me" className="flex flex-col items-center">
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
48 changes: 48 additions & 0 deletions src/pages/Timer/components/RankingItem.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className={clsx('flex items-center gap-4 px-4 py-3', item.isMe && 'bg-indigo-5')}>
<div className="w-10 text-center">
{item.rank && item.rank <= 3 ? (
<span
className={clsx(
'inline-flex h-7 w-7 items-center justify-center rounded-full text-[13px] leading-3.5 font-bold text-white',
item.rank === 1 && 'bg-[#FBBC05]',
item.rank === 2 && 'bg-[#BAC3CD]',
item.rank === 3 && 'bg-[#CA9369]'
)}
>
{item.rank}
</span>
) : (
<span className="text-[13px] leading-3.5 font-bold text-indigo-700">
{item.rank || (item.isMe ? '1290' : '')}
</span>
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-[17px] leading-[19px] font-bold text-indigo-700">{item.name}</span>
{item.school && <span className="text-[11px] leading-[15px] text-indigo-300">{item.school}</span>}
</div>
<div className="text-[13px] leading-4 text-indigo-300">
누적 공부시간 : {item.total} | 오늘 공부시간 : {item.today}
</div>
</div>
</div>
);

export default RankingItem;
37 changes: 37 additions & 0 deletions src/pages/Timer/components/TimerButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={onToggle}
style={{
height: 'min(45vh, 90vw)',
width: 'min(45vh, 90vw)',
containerType: 'size',
}}
className={clsx(
'relative mx-auto flex flex-col items-center justify-center gap-1 rounded-full',
isRunning ? 'z-40 bg-[#5f7691] text-white' : 'bg-indigo-0 z-10 border border-[#ccc] text-indigo-300'
)}
>
<div style={{ fontSize: '5cqw' }} className="font-medium">
오늘의 누적 시간
</div>
<div style={{ fontSize: '18cqw' }} className="font-semibold">
{formatTime(time)}
</div>
<div style={{ fontSize: '4.5cqw' }} className="px-2 text-center font-medium">
{isRunning ? '타이머가 작동중입니다!' : '화면을 클릭해 타이머를 작동시키세요!'}
</div>
</button>
);
}

export default TimerButton;
111 changes: 111 additions & 0 deletions src/pages/Timer/index.tsx
Original file line number Diff line number Diff line change
@@ -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<TabType>('개인');

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 (
<div className="relative h-full overflow-hidden">
{isRunning && <div className="fixed inset-0 z-30 bg-black/70" />}
<div className="py-3">
<TimerButton time={time} isRunning={isRunning} onToggle={toggle} />
</div>

<div
ref={sheetRef}
className={clsx(
'fixed inset-x-0 bottom-0 z-20 rounded-t-3xl bg-white transition-transform duration-300 ease-out',
isDragging && 'transition-none'
)}
style={{
height: 'calc(100% - 48px)',
transform: `translateY(${
position === 'half' ? `calc(55% + ${currentTranslate}px)` : `${Math.max(0, currentTranslate)}px`
})`,
}}
>
<div className="flex h-5 cursor-grab items-center justify-center active:cursor-grabbing" {...handlers}>
<div className="h-1 w-11 rounded-full bg-indigo-300" />
</div>
<div className="relative flex items-center justify-center px-4 font-semibold">
<div className="text-center text-[15px] leading-6 text-indigo-700">랭킹</div>
<button className="absolute right-5 text-sm leading-5 text-indigo-300">설정</button>
</div>

<div className="flex pt-2.5 pb-0.5">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={clsx(
'flex-1 border-b-[1.4px] py-1.5 text-center text-[13px] font-semibold',
activeTab === tab ? 'border-blue-500 text-indigo-700' : 'border-transparent text-indigo-200'
)}
>
{tab}
</button>
))}
</div>

<div className="overflow-y-auto" style={{ height: 'calc(100% - 48px)' }}>
{myRanking && <RankingItem item={myRanking} />}
{sortedRankings.map((item, index) => (
<RankingItem key={index} item={item} />
))}
</div>
</div>
</div>
);
}

export default TimerPage;
1 change: 1 addition & 0 deletions src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
--color-indigo-900: #020b16;

/* Blue Scale */
--color-blue-200: #8ec2fc;
--color-blue-500: #59a7fc;

/* ========================================
Expand Down
46 changes: 46 additions & 0 deletions src/utils/hooks/useBottomSheet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useRef, useState } from 'react';

type SheetPosition = 'half' | 'full';

export const useBottomSheet = (threshold = 30) => {
const [position, setPosition] = useState<SheetPosition>('half');
const [isDragging, setIsDragging] = useState(false);
const [currentTranslate, setCurrentTranslate] = useState(0);
const startY = useRef(0);
const sheetRef = useRef<HTMLDivElement>(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,
},
};
};
44 changes: 44 additions & 0 deletions src/utils/hooks/useTimer.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 };
};
7 changes: 7 additions & 0 deletions src/utils/ts/time.ts
Original file line number Diff line number Diff line change
@@ -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(':');
};