mirror of
https://github.com/remvze/moodist.git
synced 2025-12-19 09:54:17 +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": {
|
"rules": {
|
||||||
"import-notation": "string",
|
"import-notation": "string",
|
||||||
"selector-class-pattern": null
|
"selector-class-pattern": null,
|
||||||
|
"no-descending-specificity": null
|
||||||
},
|
},
|
||||||
|
|
||||||
"overrides": [
|
"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 { Pomodoro as PomodoroItem } from './pomodoro';
|
||||||
export { Notepad as NotepadItem } from './notepad';
|
export { Notepad as NotepadItem } from './notepad';
|
||||||
export { Todo as TodoItem } from './todo';
|
export { Todo as TodoItem } from './todo';
|
||||||
|
export { Countdown as CountdownItem } from './countdown';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
PomodoroItem,
|
PomodoroItem,
|
||||||
NotepadItem,
|
NotepadItem,
|
||||||
TodoItem,
|
TodoItem,
|
||||||
|
CountdownItem,
|
||||||
} from './items';
|
} from './items';
|
||||||
import { Divider } from './divider';
|
import { Divider } from './divider';
|
||||||
import { ShareLinkModal } from '@/components/modals/share-link';
|
import { ShareLinkModal } from '@/components/modals/share-link';
|
||||||
|
|
@ -23,7 +24,7 @@ import { PresetsModal } from '@/components/modals/presets';
|
||||||
import { ShortcutsModal } from '@/components/modals/shortcuts';
|
import { ShortcutsModal } from '@/components/modals/shortcuts';
|
||||||
import { SleepTimerModal } from '@/components/modals/sleep-timer';
|
import { SleepTimerModal } from '@/components/modals/sleep-timer';
|
||||||
import { BreathingExerciseModal } from '../modals/breathing';
|
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 { fade, mix, slideY } from '@/lib/motion';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ export function Menu() {
|
||||||
const initial = useMemo(
|
const initial = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
breathing: false,
|
breathing: false,
|
||||||
|
countdown: false,
|
||||||
notepad: false,
|
notepad: false,
|
||||||
pomodoro: false,
|
pomodoro: false,
|
||||||
presets: false,
|
presets: false,
|
||||||
|
|
@ -115,6 +117,7 @@ export function Menu() {
|
||||||
<SleepTimerItem open={() => open('sleepTimer')} />
|
<SleepTimerItem open={() => open('sleepTimer')} />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<CountdownItem open={() => open('countdown')} />
|
||||||
<PomodoroItem open={() => open('pomodoro')} />
|
<PomodoroItem open={() => open('pomodoro')} />
|
||||||
<NotepadItem open={() => open('notepad')} />
|
<NotepadItem open={() => open('notepad')} />
|
||||||
<TodoItem open={() => open('todo')} />
|
<TodoItem open={() => open('todo')} />
|
||||||
|
|
@ -153,6 +156,7 @@ export function Menu() {
|
||||||
/>
|
/>
|
||||||
<Notepad show={modals.notepad} onClose={() => close('notepad')} />
|
<Notepad show={modals.notepad} onClose={() => close('notepad')} />
|
||||||
<Todo show={modals.todo} onClose={() => close('todo')} />
|
<Todo show={modals.todo} onClose={() => close('todo')} />
|
||||||
|
<Countdown show={modals.countdown} onClose={() => close('countdown')} />
|
||||||
<PresetsModal show={modals.presets} onClose={() => close('presets')} />
|
<PresetsModal show={modals.presets} onClose={() => close('presets')} />
|
||||||
<SleepTimerModal
|
<SleepTimerModal
|
||||||
show={modals.sleepTimer}
|
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 { Notepad } from './notepad';
|
||||||
export { Pomodoro } from './pomodoro';
|
export { Pomodoro } from './pomodoro';
|
||||||
export { Todo } from './todo';
|
export { Todo } from './todo';
|
||||||
|
export { Countdown } from './countdown';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue