feat: add simple countdown timer

This commit is contained in:
MAZE 2024-08-31 17:11:56 +03:30
parent cb37b08d37
commit 24c0d58eee
8 changed files with 299 additions and 2 deletions

View file

@ -8,7 +8,8 @@
"rules": {
"import-notation": "string",
"selector-class-pattern": null
"selector-class-pattern": null,
"no-descending-specificity": null
},
"overrides": [

View file

@ -0,0 +1,13 @@
import { MdOutlineTimer } from 'react-icons/md/index';
import { Item } from '../item';
interface CountdownProps {
open: () => void;
}
export function Countdown({ open }: CountdownProps) {
return (
<Item icon={<MdOutlineTimer />} label="Countdown Timer" onClick={open} />
);
}

View file

@ -9,3 +9,4 @@ export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise
export { Pomodoro as PomodoroItem } from './pomodoro';
export { Notepad as NotepadItem } from './notepad';
export { Todo as TodoItem } from './todo';
export { Countdown as CountdownItem } from './countdown';

View file

@ -16,6 +16,7 @@ import {
PomodoroItem,
NotepadItem,
TodoItem,
CountdownItem,
} from './items';
import { Divider } from './divider';
import { ShareLinkModal } from '@/components/modals/share-link';
@ -23,7 +24,7 @@ import { PresetsModal } from '@/components/modals/presets';
import { ShortcutsModal } from '@/components/modals/shortcuts';
import { SleepTimerModal } from '@/components/modals/sleep-timer';
import { BreathingExerciseModal } from '../modals/breathing';
import { Pomodoro, Notepad, Todo } from '../toolbox';
import { Pomodoro, Notepad, Todo, Countdown } from '../toolbox';
import { fade, mix, slideY } from '@/lib/motion';
import { useSoundStore } from '@/stores/sound';
@ -39,6 +40,7 @@ export function Menu() {
const initial = useMemo(
() => ({
breathing: false,
countdown: false,
notepad: false,
pomodoro: false,
presets: false,
@ -115,6 +117,7 @@ export function Menu() {
<SleepTimerItem open={() => open('sleepTimer')} />
<Divider />
<CountdownItem open={() => open('countdown')} />
<PomodoroItem open={() => open('pomodoro')} />
<NotepadItem open={() => open('notepad')} />
<TodoItem open={() => open('todo')} />
@ -153,6 +156,7 @@ export function Menu() {
/>
<Notepad show={modals.notepad} onClose={() => close('notepad')} />
<Todo show={modals.todo} onClose={() => close('todo')} />
<Countdown show={modals.countdown} onClose={() => close('countdown')} />
<PresetsModal show={modals.presets} onClose={() => close('presets')} />
<SleepTimerModal
show={modals.sleepTimer}

View file

@ -0,0 +1,128 @@
.header {
margin-bottom: 16px;
& .title {
margin-bottom: 8px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}
& .desc {
color: var(--color-foreground-subtle);
}
}
.formContainer {
& .inputContainer {
display: flex;
column-gap: 8px;
align-items: center;
& .input {
display: block;
flex-grow: 1;
min-width: 0;
height: 45px;
padding: 0 8px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
}
& span {
display: block;
}
}
}
.displayTime {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 150px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
&::before {
position: absolute;
bottom: -1px;
left: 50%;
width: 80%;
height: 1px;
content: '';
background: linear-gradient(
90deg,
transparent,
var(--color-neutral-300),
transparent
);
transform: translateX(-50%);
}
& span {
font-size: var(--font-xlg);
font-weight: 600;
}
& .reverse {
position: absolute;
top: 4px;
left: 4px;
padding: 4px 12px;
font-size: var(--font-xsm);
color: var(--color-foreground-subtle);
background: linear-gradient(
var(--color-neutral-100),
var(--color-neutral-50)
);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
}
}
.buttonContainer {
display: flex;
column-gap: 8px;
align-items: center;
justify-content: flex-end;
margin-top: 12px;
& .button {
padding: 12px 16px;
font-family: var(--font-heading);
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-foreground-subtle);
cursor: pointer;
background-color: var(--color-neutral-200);
border: none;
border-radius: 4px;
outline: none;
transition: 0.2s;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
&:hover,
&:focus-visible {
color: var(--color-foreground);
background-color: var(--color-neutral-300);
}
&.primary {
color: var(--color-neutral-200);
background-color: var(--color-neutral-950);
&:hover,
&:focus-visible {
background-color: var(--color-neutral-800);
}
}
}
}

View file

@ -0,0 +1,148 @@
import { useState, useEffect, useCallback } from 'react';
import { Modal } from '@/components/modal';
import { useSoundEffect } from '@/hooks/use-sound-effect';
import { cn } from '@/helpers/styles';
import { padNumber } from '@/helpers/number';
import styles from './countdown.module.css';
interface CountdownProps {
onClose: () => void;
show: boolean;
}
export function Countdown({ onClose, show }: CountdownProps) {
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [seconds, setSeconds] = useState(0);
const [timeLeft, setTimeLeft] = useState(0);
const [initialTime, setInitialTime] = useState(0);
const [isActive, setIsActive] = useState(false);
const [isFormVisible, setIsFormVisible] = useState(true);
const alarm = useSoundEffect('/sounds/alarm.mp3');
useEffect(() => {
let timer: NodeJS.Timeout;
if (isActive && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft(timeLeft - 1), 1000);
} else if (timeLeft === 0 && isActive) {
alarm.play();
setIsActive(false);
setIsFormVisible(true);
}
return () => clearTimeout(timer);
}, [isActive, timeLeft, alarm]);
const handleStart = useCallback(() => {
if (hours > 0 || minutes > 0 || seconds > 0) {
const totalTime =
(hours || 0) * 3600 + (minutes || 0) * 60 + (seconds || 0);
setTimeLeft(totalTime);
setInitialTime(totalTime);
setIsActive(true);
setIsFormVisible(false);
}
}, [hours, minutes, seconds]);
const handleBack = useCallback(() => {
setIsActive(false);
setIsFormVisible(true);
setTimeLeft(0);
}, []);
const toggleTimer = useCallback(() => {
setIsActive(prev => !prev);
}, []);
const formatTime = useCallback((time: number) => {
const hrs = Math.floor(time / 3600);
const mins = Math.floor((time % 3600) / 60);
const secs = time % 60;
return `${padNumber(hrs)}:${padNumber(mins)}:${padNumber(secs)}`;
}, []);
const elapsedTime = initialTime - timeLeft;
return (
<Modal show={show} onClose={onClose}>
<header className={styles.header}>
<h2 className={styles.title}>Countdown Timer</h2>
<p className={styles.desc}>Super simple countdown timer.</p>
</header>
{isFormVisible ? (
<div className={styles.formContainer}>
<div className={styles.inputContainer}>
<input
className={styles.input}
placeholder="HH"
type="number"
value={hours}
onChange={e => setHours(Math.max(0, parseInt(e.target.value)))}
/>
<span>:</span>
<input
className={styles.input}
placeholder="MM"
type="number"
value={minutes}
onChange={e =>
setMinutes(Math.max(0, Math.min(59, parseInt(e.target.value))))
}
/>
<span>:</span>
<input
className={styles.input}
placeholder="SS"
type="number"
value={seconds}
onChange={e =>
setSeconds(Math.max(0, Math.min(59, parseInt(e.target.value))))
}
/>
</div>
<div className={styles.buttonContainer}>
<button
className={cn(styles.button, styles.primary)}
onClick={handleStart}
>
Start
</button>
</div>
</div>
) : (
<div className={styles.timerContainer}>
<div className={styles.displayTime}>
<p className={styles.reverse}>- {formatTime(elapsedTime)}</p>
<span>{formatTime(timeLeft)}</span>
</div>
<div className={styles.buttonContainer}>
<button className={styles.button} onClick={handleBack}>
Back
</button>
<button
className={cn(styles.button, styles.primary)}
onClick={toggleTimer}
>
{isActive ? 'Pause' : 'Start'}
</button>
</div>
</div>
)}
</Modal>
);
}

View file

@ -0,0 +1 @@
export { Countdown } from './countdown';

View file

@ -1,3 +1,4 @@
export { Notepad } from './notepad';
export { Pomodoro } from './pomodoro';
export { Todo } from './todo';
export { Countdown } from './countdown';