mirror of
https://github.com/remvze/moodist.git
synced 2025-12-17 08:54:13 +00:00
feat: remove the countdown timer
This commit is contained in:
parent
0052b917a8
commit
d6ed3fd251
28 changed files with 5 additions and 853 deletions
|
|
@ -19,7 +19,7 @@ const paragraphs = [
|
||||||
title: 'Create Your Soundscape',
|
title: 'Create Your Soundscape',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: 'Moodist offers more than ambient sounds with its suite of productivity tools to keep you organized and focused. Use the built-in pomodoro timer for structured work intervals, jot down ideas in the notepad, track tasks with the to-do list (coming soon), and set multiple timers with the distraction-free countdown timer. These tools integrate seamlessly with the ambient soundscapes, creating a personalized environment that fosters focus and relaxation.',
|
body: 'Moodist offers more than ambient sounds with its suite of productivity tools to keep you organized and focused. Use the built-in pomodoro timer for structured work intervals, jot down ideas in the notepad, and track tasks with the to-do list (coming soon). These tools integrate seamlessly with the ambient soundscapes, creating a personalized environment that fosters focus and relaxation.',
|
||||||
title: 'A Productivity Toolbox',
|
title: 'A Productivity Toolbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,12 @@ import { MdOutlineTimer } from 'react-icons/md/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
|
||||||
interface SleepTimerProps {
|
export function CountdownTimer() {
|
||||||
open: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CountdownTimer({ open }: SleepTimerProps) {
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
|
href="https://timesy.app"
|
||||||
icon={<MdOutlineTimer />}
|
icon={<MdOutlineTimer />}
|
||||||
label="Countdown Timer"
|
label="Countdown Timer"
|
||||||
shortcut="Shift + C"
|
|
||||||
onClick={open}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,7 @@ 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 { SleepTimerModal } from '@/components/modals/sleep-timer';
|
||||||
import {
|
import { Notepad, Pomodoro, BreathingExercise } from '@/components/toolbox';
|
||||||
Notepad,
|
|
||||||
Pomodoro,
|
|
||||||
CountdownTimer,
|
|
||||||
BreathingExercise,
|
|
||||||
} from '@/components/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';
|
||||||
|
|
||||||
|
|
@ -43,7 +38,6 @@ export function Menu() {
|
||||||
const initial = useMemo(
|
const initial = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
breathingExercise: false,
|
breathingExercise: false,
|
||||||
countdownTimer: false,
|
|
||||||
notepad: false,
|
notepad: false,
|
||||||
pomodoro: false,
|
pomodoro: false,
|
||||||
presets: false,
|
presets: false,
|
||||||
|
|
@ -75,7 +69,6 @@ export function Menu() {
|
||||||
useHotkeys('shift+m', () => setIsOpen(prev => !prev));
|
useHotkeys('shift+m', () => setIsOpen(prev => !prev));
|
||||||
useHotkeys('shift+n', () => open('notepad'));
|
useHotkeys('shift+n', () => open('notepad'));
|
||||||
useHotkeys('shift+p', () => open('pomodoro'));
|
useHotkeys('shift+p', () => open('pomodoro'));
|
||||||
useHotkeys('shift+c', () => open('countdownTimer'));
|
|
||||||
useHotkeys('shift+b', () => open('breathingExercise'));
|
useHotkeys('shift+b', () => open('breathingExercise'));
|
||||||
useHotkeys('shift+alt+p', () => open('presets'));
|
useHotkeys('shift+alt+p', () => open('presets'));
|
||||||
useHotkeys('shift+h', () => open('shortcuts'));
|
useHotkeys('shift+h', () => open('shortcuts'));
|
||||||
|
|
@ -124,7 +117,7 @@ export function Menu() {
|
||||||
/>
|
/>
|
||||||
<PomodoroItem open={() => open('pomodoro')} />
|
<PomodoroItem open={() => open('pomodoro')} />
|
||||||
<NotepadItem open={() => open('notepad')} />
|
<NotepadItem open={() => open('notepad')} />
|
||||||
<CountdownTimerItem open={() => open('countdownTimer')} />
|
<CountdownTimerItem />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<ShortcutsItem open={() => open('shortcuts')} />
|
<ShortcutsItem open={() => open('shortcuts')} />
|
||||||
|
|
@ -159,10 +152,6 @@ export function Menu() {
|
||||||
show={modals.breathingExercise}
|
show={modals.breathingExercise}
|
||||||
onClose={() => close('breathingExercise')}
|
onClose={() => close('breathingExercise')}
|
||||||
/>
|
/>
|
||||||
<CountdownTimer
|
|
||||||
show={modals.countdownTimer}
|
|
||||||
onClose={() => close('countdownTimer')}
|
|
||||||
/>
|
|
||||||
<SleepTimerModal
|
<SleepTimerModal
|
||||||
show={modals.sleepTimer}
|
show={modals.sleepTimer}
|
||||||
onClose={() => close('sleepTimer')}
|
onClose={() => close('sleepTimer')}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ export function ShortcutsModal({ onClose, show }: ShortcutsModalProps) {
|
||||||
keys: ['Shift', 'B'],
|
keys: ['Shift', 'B'],
|
||||||
label: 'Breathing Exercise',
|
label: 'Breathing Exercise',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
keys: ['Shift', 'C'],
|
|
||||||
label: 'Countdown Timer',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
keys: ['Shift', 'T'],
|
keys: ['Shift', 'T'],
|
||||||
label: 'Sleep Timer',
|
label: 'Sleep Timer',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
.title {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--font-md);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
|
||||||
|
|
||||||
import { Modal } from '@/components/modal';
|
|
||||||
|
|
||||||
import { Form } from './form';
|
|
||||||
import { Timers } from './timers';
|
|
||||||
|
|
||||||
import styles from './countdown-timer.module.css';
|
|
||||||
|
|
||||||
interface TimerProps {
|
|
||||||
onClose: () => void;
|
|
||||||
show: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CountdownTimer({ onClose, show }: TimerProps) {
|
|
||||||
const [containerRef, enableAnimations] = useAutoAnimate<HTMLDivElement>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal persist show={show} onClose={onClose}>
|
|
||||||
<h2 className={styles.title}>Countdown Timer</h2>
|
|
||||||
<Form enableAnimations={enableAnimations} />
|
|
||||||
<Timers enableAnimations={enableAnimations} ref={containerRef} />
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
.field {
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
& .label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
& .optional {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-foreground-subtle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
color: var(--color-foreground);
|
|
||||||
background-color: var(--color-neutral-50);
|
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
border-radius: 4px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import styles from './field.module.css';
|
|
||||||
|
|
||||||
interface FieldProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
onChange: (value: string | number) => void;
|
|
||||||
optional?: boolean;
|
|
||||||
type: 'text' | 'select';
|
|
||||||
value: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Field({
|
|
||||||
children,
|
|
||||||
label,
|
|
||||||
onChange,
|
|
||||||
optional,
|
|
||||||
type,
|
|
||||||
value,
|
|
||||||
}: FieldProps) {
|
|
||||||
return (
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label} htmlFor={label.toLowerCase()}>
|
|
||||||
{label}{' '}
|
|
||||||
{optional && <span className={styles.optional}>(optional)</span>}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{type === 'text' && (
|
|
||||||
<input
|
|
||||||
autoComplete="off"
|
|
||||||
className={styles.input}
|
|
||||||
id={label.toLowerCase()}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === 'select' && (
|
|
||||||
<select
|
|
||||||
autoComplete="off"
|
|
||||||
className={styles.input}
|
|
||||||
id={label.toLowerCase()}
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(parseInt(e.target.value))}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Field } from './field';
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 28px;
|
|
||||||
|
|
||||||
& .button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 45px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-neutral-50);
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-neutral-950);
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeFields {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 12px;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { Field } from './field';
|
|
||||||
|
|
||||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
|
||||||
import { waitUntil } from '@/helpers/wait';
|
|
||||||
|
|
||||||
import styles from './form.module.css';
|
|
||||||
|
|
||||||
interface FormProps {
|
|
||||||
enableAnimations: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Form({ enableAnimations }: FormProps) {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [hours, setHours] = useState(0);
|
|
||||||
const [minutes, setMinutes] = useState(10);
|
|
||||||
const [seconds, setSeconds] = useState(0);
|
|
||||||
|
|
||||||
const totalSeconds = useMemo(
|
|
||||||
() => hours * 60 * 60 + minutes * 60 + seconds,
|
|
||||||
[hours, minutes, seconds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const add = useCountdownTimers(state => state.add);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (totalSeconds === 0) return;
|
|
||||||
|
|
||||||
enableAnimations(false);
|
|
||||||
|
|
||||||
const id = add({
|
|
||||||
name,
|
|
||||||
total: totalSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
setName('');
|
|
||||||
|
|
||||||
await waitUntil(() => !!document.getElementById(`timer-${id}`), 50);
|
|
||||||
|
|
||||||
document
|
|
||||||
.getElementById(`timer-${id}`)
|
|
||||||
?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
|
|
||||||
enableAnimations(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
|
||||||
<Field
|
|
||||||
label="Timer Name"
|
|
||||||
optional
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={value => setName(value as string)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.timeFields}>
|
|
||||||
<Field
|
|
||||||
label="Hours"
|
|
||||||
type="select"
|
|
||||||
value={hours}
|
|
||||||
onChange={value => setHours(value as number)}
|
|
||||||
>
|
|
||||||
{Array(13)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => (
|
|
||||||
<option key={`hour-${index}`} value={index}>
|
|
||||||
{index}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label="Minutes"
|
|
||||||
type="select"
|
|
||||||
value={minutes}
|
|
||||||
onChange={value => setMinutes(value as number)}
|
|
||||||
>
|
|
||||||
{Array(60)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => (
|
|
||||||
<option key={`minutes-${index}`} value={index}>
|
|
||||||
{index}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label="Seconds"
|
|
||||||
type="select"
|
|
||||||
value={seconds}
|
|
||||||
onChange={value => setSeconds(value as number)}
|
|
||||||
>
|
|
||||||
{Array(60)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, index) => (
|
|
||||||
<option key={`seconds-${index}`} value={index}>
|
|
||||||
{index}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className={styles.button} type="submit">
|
|
||||||
Add Timer
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Form } from './form';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { CountdownTimer } from './countdown-timer';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Timers } from './timers';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Notice } from './notice';
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
.notice {
|
|
||||||
padding: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
font-size: var(--font-sm);
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--color-foreground-subtle);
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--color-neutral-50);
|
|
||||||
border: 1px dashed var(--color-neutral-300);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import styles from './notice.module.css';
|
|
||||||
|
|
||||||
export function Notice() {
|
|
||||||
return (
|
|
||||||
<p className={styles.notice}>
|
|
||||||
Please do not close your browser tab while timers are running, otherwise
|
|
||||||
all timers will be stopped.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Timer } from './timer';
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
.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;
|
|
||||||
min-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;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
color: var(--color-foreground-subtle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
import { useRef, useMemo, useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
IoPlay,
|
|
||||||
IoPause,
|
|
||||||
IoRefresh,
|
|
||||||
IoTrashOutline,
|
|
||||||
} from 'react-icons/io5/index';
|
|
||||||
|
|
||||||
import { Toolbar } from './toolbar';
|
|
||||||
|
|
||||||
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 {
|
|
||||||
enableAnimations: (enabled: boolean) => void;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Timer({ enableAnimations, 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 { first, last, 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 [isReversed, setIsReversed] = useState(false);
|
|
||||||
|
|
||||||
const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]);
|
|
||||||
const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
|
|
||||||
const spentSeconds = useMemo(() => spent % 60, [spent]);
|
|
||||||
|
|
||||||
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.');
|
|
||||||
|
|
||||||
enableAnimations(false);
|
|
||||||
|
|
||||||
deleteTimer(id);
|
|
||||||
|
|
||||||
setTimeout(() => enableAnimations(true), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
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} id={`timer-${id}`}>
|
|
||||||
<header className={styles.header}>
|
|
||||||
<div className={styles.bar}>
|
|
||||||
<div
|
|
||||||
className={styles.completed}
|
|
||||||
style={{ width: `${(left / total) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Toolbar first={first} id={id} last={last} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={styles.left}
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setIsReversed(prev => !prev)}
|
|
||||||
onKeyDown={() => setIsReversed(prev => !prev)}
|
|
||||||
>
|
|
||||||
{!isReversed ? (
|
|
||||||
<>
|
|
||||||
{padNumber(hours)}
|
|
||||||
<span>:</span>
|
|
||||||
{padNumber(minutes)}
|
|
||||||
<span>:</span>
|
|
||||||
{padNumber(seconds)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>-</span>
|
|
||||||
{padNumber(spentHours)}
|
|
||||||
<span>:</span>
|
|
||||||
{padNumber(spentMinutes)}
|
|
||||||
<span>:</span>
|
|
||||||
{padNumber(spentSeconds)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Toolbar } from './toolbar';
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
.toolbar {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
display: flex;
|
|
||||||
column-gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
height: 30px;
|
|
||||||
padding: 4px;
|
|
||||||
background-color: var(--color-neutral-50);
|
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
& button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
font-size: var(--font-xsm);
|
|
||||||
color: var(--color-foreground-subtle);
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
transition: 0.2s;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled):hover {
|
|
||||||
color: var(--color-foreground);
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io/index';
|
|
||||||
|
|
||||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
|
||||||
|
|
||||||
import styles from './toolbar.module.css';
|
|
||||||
|
|
||||||
interface ToolbarProps {
|
|
||||||
first: boolean;
|
|
||||||
id: string;
|
|
||||||
last: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Toolbar({ first, id, last }: ToolbarProps) {
|
|
||||||
const moveUp = useCountdownTimers(state => state.moveUp);
|
|
||||||
const moveDown = useCountdownTimers(state => state.moveDown);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.toolbar}>
|
|
||||||
<button
|
|
||||||
disabled={first}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
moveUp(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IoIosArrowUp />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={last}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
moveDown(id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IoIosArrowDown />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
.timers {
|
|
||||||
margin-top: 48px;
|
|
||||||
|
|
||||||
& > header {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
& .title {
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--font-md);
|
|
||||||
font-weight: 600;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useMemo, forwardRef } from 'react';
|
|
||||||
|
|
||||||
import { Timer } from './timer';
|
|
||||||
import { Notice } from './notice';
|
|
||||||
|
|
||||||
import { useCountdownTimers } from '@/stores/countdown-timers';
|
|
||||||
|
|
||||||
import styles from './timers.module.css';
|
|
||||||
|
|
||||||
interface TimersProps {
|
|
||||||
enableAnimations: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Timers = forwardRef(function Timers(
|
|
||||||
{ enableAnimations }: TimersProps,
|
|
||||||
ref: React.ForwardedRef<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div ref={ref}>
|
|
||||||
{timers.map(timer => (
|
|
||||||
<Timer
|
|
||||||
enableAnimations={enableAnimations}
|
|
||||||
id={timer.id}
|
|
||||||
key={timer.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Notice />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export { Notepad } from './notepad';
|
export { Notepad } from './notepad';
|
||||||
export { Pomodoro } from './pomodoro';
|
export { Pomodoro } from './pomodoro';
|
||||||
export { CountdownTimer } from './countdown-timer';
|
|
||||||
export { BreathingExercise } from './breathing';
|
export { BreathingExercise } from './breathing';
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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…
Add table
Reference in a new issue