feat: add sleep timer

This commit is contained in:
Jef Roelandt 2024-04-27 19:06:46 +02:00
parent 60f167c4d7
commit 71b62ed3dd
15 changed files with 244 additions and 17 deletions

View file

@ -0,0 +1,36 @@
import { padNumber } from '@/helpers/number';
import styles from './timer.module.css';
interface TimerProps {
displayHours?: boolean;
timer: number;
}
export function Timer({ displayHours = false, timer }: TimerProps) {
let hours = Math.floor(timer / 3600);
let minutes = Math.floor((timer % 3600) / 60);
let seconds = timer % 60;
hours = isNaN(hours) ? 0 : hours;
minutes = isNaN(minutes) ? 0 : minutes;
seconds = isNaN(seconds) ? 0 : seconds;
const formattedHours = padNumber(hours);
const formattedMinutes = padNumber(minutes);
const formattedSeconds = padNumber(seconds);
return (
<div className={styles.timer}>
{displayHours ? (
<>
{formattedHours}:{formattedMinutes}:{formattedSeconds}
</>
) : (
<>
{formattedMinutes}:{formattedSeconds}
</>
)}
</div>
);
}

View file

@ -6,3 +6,4 @@ export { Source as SourceItem } from './source';
export { Pomodoro as PomodoroItem } from './pomodoro'; export { Pomodoro as PomodoroItem } from './pomodoro';
export { Presets as PresetsItem } from './presets'; export { Presets as PresetsItem } from './presets';
export { Shortcuts as ShortcutsItem } from './shortcuts'; export { Shortcuts as ShortcutsItem } from './shortcuts';
export { SleepTimer } from './sleep-timer';

View file

@ -0,0 +1,18 @@
import { IoMoonSharp } from 'react-icons/io5/index';
import { Item } from '../item';
interface SleepTimerProps {
open: () => void;
}
export function SleepTimer({ open }: SleepTimerProps) {
return (
<Item
icon={<IoMoonSharp />}
label="Sleep timer"
shortcut="Shift + T"
onClick={open}
/>
);
}

View file

@ -13,11 +13,13 @@ import {
PomodoroItem, PomodoroItem,
PresetsItem, PresetsItem,
ShortcutsItem, ShortcutsItem,
SleepTimer,
} 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';
import { PresetsModal } from '@/components/modals/presets'; 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 { Notepad, Pomodoro } from '@/components/toolbox'; import { Notepad, Pomodoro } from '@/components/toolbox';
import { fade, mix, slideY } from '@/lib/motion'; import { fade, mix, slideY } from '@/lib/motion';
import { useSoundStore } from '@/store'; import { useSoundStore } from '@/store';
@ -37,6 +39,7 @@ export function Menu() {
presets: false, presets: false,
shareLink: false, shareLink: false,
shortcuts: false, shortcuts: false,
sleepTimer: false,
}), }),
[], [],
); );
@ -64,6 +67,7 @@ export function Menu() {
useHotkeys('shift+alt+p', () => open('presets')); useHotkeys('shift+alt+p', () => open('presets'));
useHotkeys('shift+h', () => open('shortcuts')); useHotkeys('shift+h', () => open('shortcuts'));
useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected }); useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected });
useHotkeys('shift+t', () => open('sleepTimer'));
useCloseListener(closeAll); useCloseListener(closeAll);
@ -71,6 +75,7 @@ export function Menu() {
return ( return (
<> <>
<Divider />
<div className={styles.wrapper}> <div className={styles.wrapper}>
<DropdownMenu.Root open={isOpen} onOpenChange={o => setIsOpen(o)}> <DropdownMenu.Root open={isOpen} onOpenChange={o => setIsOpen(o)}>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
@ -103,6 +108,7 @@ export function Menu() {
<Divider /> <Divider />
<NotepadItem open={() => open('notepad')} /> <NotepadItem open={() => open('notepad')} />
<PomodoroItem open={() => open('pomodoro')} /> <PomodoroItem open={() => open('pomodoro')} />
<SleepTimer open={() => open('sleepTimer')} />
<Divider /> <Divider />
<ShortcutsItem open={() => open('shortcuts')} /> <ShortcutsItem open={() => open('shortcuts')} />
@ -133,6 +139,10 @@ export function Menu() {
show={modals.pomodoro} show={modals.pomodoro}
onClose={() => close('pomodoro')} onClose={() => close('pomodoro')}
/> />
<SleepTimerModal
show={modals.sleepTimer}
onClose={() => close('sleepTimer')}
/>
</> </>
); );
} }

View file

@ -29,6 +29,10 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
keys: ['Shift', 'P'], keys: ['Shift', 'P'],
label: 'Pomodoro Timer', label: 'Pomodoro Timer',
}, },
{
keys: ['Shift', 'T'],
label: 'Sleep Timer',
},
{ {
keys: ['Shift', 'Space'], keys: ['Shift', 'Space'],
label: 'Toggle Play', label: 'Toggle Play',

View file

@ -0,0 +1 @@
export { SleepTimerModal } from './sleep-timer';

View file

@ -0,0 +1,45 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
& .title {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground-subtle);
}
}
.controls {
display: flex;
flex-flow: column wrap;
align-items: flex-start;
margin-top: 8px;
& .inputContainer {
display: flex;
align-items: center;
& .input {
display: block;
height: 40px;
padding: 0 8px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
}
& .label {
width: 100px;
}
}
& .buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
}

View file

@ -0,0 +1,127 @@
import { useEffect, useState, useCallback } from 'react';
import { Modal } from '@/components/modal';
import { FaPlay, FaUndo } from 'react-icons/fa/index';
import { useSoundStore } from '@/store';
import { Button } from '@/components/generic/button';
import { Timer } from '@/components/generic/timer';
import styles from './sleep-timer.module.css';
interface SleepTimerModalProps {
onClose: () => void;
show: boolean;
}
export function SleepTimerModal({ onClose, show }: SleepTimerModalProps) {
const [hours, setHours] = useState<string>('0');
const [minutes, setMinutes] = useState<string>('0');
const [running, setRunning] = useState(false);
const [timeLeft, setTimeLeft] = useState(0);
const [timerId, setTimerId] = useState<NodeJS.Timeout | undefined>(undefined);
const pause = useSoundStore(state => state.pause);
const calculateTotalSeconds = useCallback((): number => {
return (
(hours === '' ? 0 : parseInt(hours)) * 3600 +
(minutes === '' ? 0 : parseInt(minutes)) * 60
);
}, [minutes, hours]);
useEffect(() => {
setTimeLeft(calculateTotalSeconds());
}, [calculateTotalSeconds]);
// Handle multiple clicks on this. Only the latest click should be taken into account
const handleStart = () => {
if (timerId) clearInterval(timerId);
setTimeLeft(calculateTotalSeconds);
setRunning(true);
if (timeLeft > 0) {
const newTimerId = setInterval(() => {
setTimeLeft(prevTimeLeft => {
const newTimeLeft = prevTimeLeft - 1;
if (newTimeLeft <= 0) {
clearInterval(newTimerId);
pause();
setRunning(false);
return 0;
}
return newTimeLeft;
});
}, 1000);
setTimerId(newTimerId);
}
};
const handleReset = () => {
if (timerId) clearInterval(timerId);
setTimeLeft(0);
setRunning(false);
};
return (
<Modal show={show} onClose={onClose}>
<header className={styles.header}>
<h2 className={styles.title}>Sleep Timer</h2>
</header>
<div className={styles.controls}>
{!running && (
<div className={styles.inputContainer}>
<label className={styles.label} htmlFor="hours">
Hours:
</label>
<input
className={styles.input}
id="hours"
min="0"
type="number"
value={hours}
onChange={e =>
setHours(e.target.value === '' ? '' : e.target.value)
}
/>
</div>
)}
{!running && (
<div className={styles.inputContainer}>
<label className={styles.label} htmlFor="minutes">
Minutes:
</label>
<input
className={styles.input}
max="59"
min="0"
type="number"
value={minutes}
onChange={e =>
setMinutes(e.target.value === '' ? '' : e.target.value)
}
/>
</div>
)}
{running ? <Timer displayHours={true} timer={timeLeft} /> : null}
<div className={styles.buttons}>
<Button
icon={<FaUndo />}
smallIcon
tooltip="Reset"
onClick={handleReset}
/>
{!running && (
<Button
icon={<FaPlay />}
smallIcon
tooltip={'Start'}
onClick={handleStart}
/>
)}
</div>
</div>
</Modal>
);
}

View file

@ -3,9 +3,9 @@ import { FaUndo, FaPlay, FaPause } from 'react-icons/fa/index';
import { IoMdSettings } from 'react-icons/io/index'; import { IoMdSettings } from 'react-icons/io/index';
import { Modal } from '@/components/modal'; import { Modal } from '@/components/modal';
import { Button } from '@/components/generic/button';
import { Timer } from '@/components/generic/timer';
import { Tabs } from './tabs'; import { Tabs } from './tabs';
import { Timer } from './timer';
import { Button } from './button';
import { Setting } from './setting'; import { Setting } from './setting';
import { useLocalStorage } from '@/hooks/use-local-storage'; import { useLocalStorage } from '@/hooks/use-local-storage';

View file

@ -1,15 +0,0 @@
import { padNumber } from '@/helpers/number';
import styles from './timer.module.css';
interface TimerProps {
timer: number;
}
export function Timer({ timer }: TimerProps) {
return (
<div className={styles.timer}>
{padNumber(Math.floor(timer / 60))}:{padNumber(timer % 60)}
</div>
);
}