mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 09:24:14 +00:00
feat: add simple countdown timer
This commit is contained in:
parent
cb37b08d37
commit
24c0d58eee
8 changed files with 299 additions and 2 deletions
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
"rules": {
|
||||
"import-notation": "string",
|
||||
"selector-class-pattern": null
|
||||
"selector-class-pattern": null,
|
||||
"no-descending-specificity": null
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
|
|
|
|||
13
src/components/menu/items/countdown.tsx
Normal file
13
src/components/menu/items/countdown.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
128
src/components/toolbox/countdown/countdown.module.css
Normal file
128
src/components/toolbox/countdown/countdown.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/components/toolbox/countdown/countdown.tsx
Normal file
148
src/components/toolbox/countdown/countdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/toolbox/countdown/index.ts
Normal file
1
src/components/toolbox/countdown/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Countdown } from './countdown';
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { Notepad } from './notepad';
|
||||
export { Pomodoro } from './pomodoro';
|
||||
export { Todo } from './todo';
|
||||
export { Countdown } from './countdown';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue