mirror of
https://github.com/remvze/moodist.git
synced 2025-09-29 15:30:49 -04:00
feat: implement countdown timer functionality
This commit is contained in:
parent
c272914416
commit
2bfb9b181c
@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
||||
import { useSoundStore } from '@/stores/sound';
|
||||
import { useNoteStore } from '@/stores/note';
|
||||
import { usePresetStore } from '@/stores/preset';
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
|
||||
interface StoreConsumerProps {
|
||||
children: React.ReactNode;
|
||||
@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||
useSoundStore.persist.rehydrate();
|
||||
useNoteStore.persist.rehydrate();
|
||||
usePresetStore.persist.rehydrate();
|
||||
useCountdownTimers.persist.rehydrate();
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
import { Form } from './form';
|
||||
import { Timers } from './timers';
|
||||
|
||||
interface TimerProps {
|
||||
onClose: () => void;
|
||||
@ -10,8 +11,8 @@ interface TimerProps {
|
||||
export function CountdownTimer({ onClose, show }: TimerProps) {
|
||||
return (
|
||||
<Modal show={show} onClose={onClose}>
|
||||
<h1>Hello World</h1>
|
||||
<Form />
|
||||
<Timers />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
1
src/components/toolbox/countdown-timer/timers/index.ts
Normal file
1
src/components/toolbox/countdown-timer/timers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Timers } from './timers';
|
@ -0,0 +1 @@
|
||||
export { Notice } from './notice';
|
@ -0,0 +1,10 @@
|
||||
.notice {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--color-foreground-subtle);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import styles from './notice.module.css';
|
||||
|
||||
export function Notice() {
|
||||
return (
|
||||
<p className={styles.notice}>
|
||||
Please do not close this tab while timers are running, otherwise all
|
||||
timers will be stopped.
|
||||
</p>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { Timer } from './timer';
|
@ -0,0 +1 @@
|
||||
export { ReverseTimer } from './reverse-timer';
|
@ -0,0 +1,9 @@
|
||||
.reverseTimer {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
font-size: var(--font-xsm);
|
||||
color: var(--color-foreground-subtle);
|
||||
letter-spacing: 1px;
|
||||
transform: translate(-50%);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { padNumber } from '@/helpers/number';
|
||||
|
||||
import styles from './reverse-timer.module.css';
|
||||
|
||||
interface ReverseTimerProps {
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export function ReverseTimer({ spent }: ReverseTimerProps) {
|
||||
const hours = useMemo(() => Math.floor(spent / 3600), [spent]);
|
||||
const minutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
|
||||
const seconds = useMemo(() => spent % 60, [spent]);
|
||||
|
||||
return (
|
||||
<div className={styles.reverseTimer}>
|
||||
-{padNumber(hours)}:{padNumber(minutes)}:{padNumber(seconds)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
.timer {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
& .header {
|
||||
position: relative;
|
||||
top: -8px;
|
||||
width: 100%;
|
||||
|
||||
& .bar {
|
||||
height: 2px;
|
||||
margin: 0 -8px;
|
||||
background-color: var(--color-neutral-200);
|
||||
|
||||
& .completed {
|
||||
height: 100%;
|
||||
background-color: var(--color-neutral-500);
|
||||
transition: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .footer {
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
& .control {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 4px;
|
||||
background-color: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 4px;
|
||||
|
||||
& .input {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
color: var(--color-foreground-subtle);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&.finished {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
& .button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-neutral-200);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&.reset {
|
||||
background-color: var(--color-neutral-100);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: #f43f5e;
|
||||
cursor: pointer;
|
||||
background-color: rgb(244 63 94 / 10%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-2xlg);
|
||||
font-weight: 700;
|
||||
|
||||
& span {
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
}
|
188
src/components/toolbox/countdown-timer/timers/timer/timer.tsx
Normal file
188
src/components/toolbox/countdown-timer/timers/timer/timer.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5';
|
||||
|
||||
import { ReverseTimer } from './reverse-timer';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
import { useAlarm } from '@/hooks/use-alarm';
|
||||
import { useSnackbar } from '@/contexts/snackbar';
|
||||
import { padNumber } from '@/helpers/number';
|
||||
import { cn } from '@/helpers/styles';
|
||||
|
||||
import styles from './timer.module.css';
|
||||
|
||||
interface TimerProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function Timer({ id }: TimerProps) {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastActiveTimeRef = useRef<number | null>(null);
|
||||
const lastStateRef = useRef<{ spent: number; total: number } | null>(null);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const { name, spent, total } = useCountdownTimers(state =>
|
||||
state.getTimer(id),
|
||||
);
|
||||
const tick = useCountdownTimers(state => state.tick);
|
||||
const rename = useCountdownTimers(state => state.rename);
|
||||
const reset = useCountdownTimers(state => state.reset);
|
||||
const deleteTimer = useCountdownTimers(state => state.delete);
|
||||
|
||||
const left = useMemo(() => total - spent, [total, spent]);
|
||||
|
||||
const hours = useMemo(() => Math.floor(left / 3600), [left]);
|
||||
const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]);
|
||||
const seconds = useMemo(() => left % 60, [left]);
|
||||
|
||||
const playAlarm = useAlarm();
|
||||
|
||||
const showSnackbar = useSnackbar();
|
||||
|
||||
const handleStart = () => {
|
||||
if (left > 0) setIsRunning(true);
|
||||
};
|
||||
|
||||
const handlePause = () => setIsRunning(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isRunning) handlePause();
|
||||
else handleStart();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (spent === 0) return;
|
||||
|
||||
if (isRunning) return showSnackbar('Please first stop the timer.');
|
||||
|
||||
setIsRunning(false);
|
||||
reset(id);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (isRunning) return showSnackbar('Please first stop the timer.');
|
||||
|
||||
deleteTimer(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => tick(id), 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isRunning, tick, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (left === 0 && isRunning) {
|
||||
setIsRunning(false);
|
||||
playAlarm();
|
||||
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, [left, isRunning, playAlarm]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBlur = () => {
|
||||
if (isRunning) {
|
||||
lastActiveTimeRef.current = Date.now();
|
||||
lastStateRef.current = { spent, total };
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isRunning && lastActiveTimeRef.current && lastStateRef.current) {
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - lastActiveTimeRef.current) / 1000,
|
||||
);
|
||||
const previousLeft =
|
||||
lastStateRef.current.total - lastStateRef.current.spent;
|
||||
const currentLeft = left;
|
||||
const correctedLeft = previousLeft - elapsed;
|
||||
|
||||
if (correctedLeft < currentLeft) {
|
||||
tick(id, currentLeft - correctedLeft);
|
||||
}
|
||||
|
||||
lastActiveTimeRef.current = null;
|
||||
lastStateRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('blur', handleBlur);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, [isRunning, tick, id, spent, total, left]);
|
||||
|
||||
return (
|
||||
<div className={styles.timer}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={styles.completed}
|
||||
style={{ width: `${(left / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ReverseTimer spent={spent} />
|
||||
|
||||
<div className={styles.left}>
|
||||
{padNumber(hours)}
|
||||
<span>:</span>
|
||||
{padNumber(minutes)}
|
||||
<span>:</span>
|
||||
{padNumber(seconds)}
|
||||
</div>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.control}>
|
||||
<input
|
||||
className={cn(styles.input, left === 0 && styles.finished)}
|
||||
placeholder="Untitled"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => rename(id, e.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
aria-disabled={isRunning || spent === 0}
|
||||
className={cn(
|
||||
styles.button,
|
||||
styles.reset,
|
||||
(isRunning || spent === 0) && styles.disabled,
|
||||
)}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<IoRefresh />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.button}
|
||||
disabled={!isRunning && left === 0}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{isRunning ? <IoPause /> : <IoPlay />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-disabled={isRunning}
|
||||
className={cn(styles.delete, isRunning && styles.disabled)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<IoTrashOutline />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
.timers {
|
||||
margin-top: 48px;
|
||||
|
||||
& > header {
|
||||
display: flex;
|
||||
column-gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
& .title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--font-lg);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
& .line {
|
||||
flex-grow: 1;
|
||||
height: 0;
|
||||
border-top: 1px dashed var(--color-neutral-200);
|
||||
}
|
||||
|
||||
& .spent {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-foreground-subtle);
|
||||
}
|
||||
}
|
||||
}
|
42
src/components/toolbox/countdown-timer/timers/timers.tsx
Normal file
42
src/components/toolbox/countdown-timer/timers/timers.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Timer } from './timer';
|
||||
import { Notice } from './notice';
|
||||
|
||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
||||
|
||||
import styles from './timers.module.css';
|
||||
|
||||
export function Timers() {
|
||||
const timers = useCountdownTimers(state => state.timers);
|
||||
const spent = useCountdownTimers(state => state.spent());
|
||||
const total = useCountdownTimers(state => state.total());
|
||||
|
||||
const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]);
|
||||
const totalMinutes = useMemo(() => Math.floor(total / 60), [total]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{timers.length > 0 ? (
|
||||
<div className={styles.timers}>
|
||||
<header>
|
||||
<h2 className={styles.title}>Timers</h2>
|
||||
<div className={styles.line} />
|
||||
{totalMinutes > 0 && (
|
||||
<p className={styles.spent}>
|
||||
{spentMinutes} / {totalMinutes} Minute
|
||||
{totalMinutes !== 1 && 's'}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{timers.map(timer => (
|
||||
<Timer id={timer.id} key={timer.id} />
|
||||
))}
|
||||
|
||||
<Notice />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/hooks/use-alarm.ts
Normal file
24
src/hooks/use-alarm.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useSound } from './use-sound';
|
||||
import { useAlarmStore } from '@/stores/alarm';
|
||||
|
||||
export function useAlarm() {
|
||||
const { play: playSound } = useSound(
|
||||
'/sounds/alarm.mp3',
|
||||
{ volume: 1 },
|
||||
true,
|
||||
);
|
||||
const isPlaying = useAlarmStore(state => state.isPlaying);
|
||||
const play = useAlarmStore(state => state.play);
|
||||
const stop = useAlarmStore(state => state.stop);
|
||||
|
||||
const playAlarm = useCallback(() => {
|
||||
if (!isPlaying) {
|
||||
playSound(stop);
|
||||
play();
|
||||
}
|
||||
}, [isPlaying, playSound, play, stop]);
|
||||
|
||||
return playAlarm;
|
||||
}
|
@ -26,7 +26,8 @@ import { FADE_OUT } from '@/constants/events';
|
||||
*/
|
||||
export function useSound(
|
||||
src: string,
|
||||
options: { loop?: boolean; volume?: number } = {},
|
||||
options: { loop?: boolean; preload?: boolean; volume?: number } = {},
|
||||
html5: boolean = false,
|
||||
) {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const isLoading = useLoadingStore(state => state.loaders[src]);
|
||||
@ -38,17 +39,18 @@ export function useSound(
|
||||
|
||||
if (isBrowser) {
|
||||
sound = new Howl({
|
||||
html5,
|
||||
onload: () => {
|
||||
setIsLoading(src, false);
|
||||
setHasLoaded(true);
|
||||
},
|
||||
preload: false,
|
||||
preload: options.preload ?? false,
|
||||
src: src,
|
||||
});
|
||||
}
|
||||
|
||||
return sound;
|
||||
}, [src, isBrowser, setIsLoading]);
|
||||
}, [src, isBrowser, setIsLoading, html5, options.preload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sound) {
|
||||
@ -60,7 +62,8 @@ export function useSound(
|
||||
if (sound) sound.volume(options.volume ?? 0.5);
|
||||
}, [sound, options.volume]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
const play = useCallback(
|
||||
(cb?: () => void) => {
|
||||
if (sound) {
|
||||
if (!hasLoaded && !isLoading) {
|
||||
setIsLoading(src, true);
|
||||
@ -70,8 +73,12 @@ export function useSound(
|
||||
if (!sound.playing()) {
|
||||
sound.play();
|
||||
}
|
||||
|
||||
if (typeof cb === 'function') sound.once('end', cb);
|
||||
}
|
||||
}, [src, setIsLoading, sound, hasLoaded, isLoading]);
|
||||
},
|
||||
[src, setIsLoading, sound, hasLoaded, isLoading],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (sound) sound.stop();
|
||||
|
19
src/stores/alarm/index.ts
Normal file
19
src/stores/alarm/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AlarmStore {
|
||||
isPlaying: boolean;
|
||||
play: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export const useAlarmStore = create<AlarmStore>()(set => ({
|
||||
isPlaying: false,
|
||||
|
||||
play() {
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
|
||||
stop() {
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
}));
|
Loading…
x
Reference in New Issue
Block a user