mirror of
https://github.com/remvze/moodist.git
synced 2025-12-17 08:54:13 +00:00
feat: add countdown timer
This commit is contained in:
parent
302a71cdc6
commit
edd53d8102
26 changed files with 951 additions and 1 deletions
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -3602,7 +3602,8 @@
|
||||||
"node_modules/@formkit/auto-animate": {
|
"node_modules/@formkit/auto-animate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.2.tgz",
|
||||||
"integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ=="
|
"integrity": "sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.11.11",
|
"version": "0.11.11",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
import { useNoteStore } from '@/stores/note';
|
import { useNoteStore } from '@/stores/note';
|
||||||
import { usePresetStore } from '@/stores/preset';
|
import { usePresetStore } from '@/stores/preset';
|
||||||
|
import { useTimers } from '@/stores/timers';
|
||||||
|
|
||||||
interface StoreConsumerProps {
|
interface StoreConsumerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||||
useSoundStore.persist.rehydrate();
|
useSoundStore.persist.rehydrate();
|
||||||
useNoteStore.persist.rehydrate();
|
useNoteStore.persist.rehydrate();
|
||||||
usePresetStore.persist.rehydrate();
|
usePresetStore.persist.rehydrate();
|
||||||
|
useTimers.persist.rehydrate();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
27
src/components/tools/timer/form/field/field.module.css
Normal file
27
src/components/tools/timer/form/field/field.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
.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-100);
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/components/tools/timer/form/field/field.tsx
Normal file
51
src/components/tools/timer/form/field/field.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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
src/components/tools/timer/form/field/index.ts
Normal file
1
src/components/tools/timer/form/field/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Field } from './field';
|
||||||
28
src/components/tools/timer/form/form.module.css
Normal file
28
src/components/tools/timer/form/form.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.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: 8px;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 -3px 0 var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeFields {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
97
src/components/tools/timer/form/form.tsx
Normal file
97
src/components/tools/timer/form/form.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Field } from './field';
|
||||||
|
|
||||||
|
import { useTimers } from '@/stores/timers';
|
||||||
|
|
||||||
|
import styles from './form.module.css';
|
||||||
|
|
||||||
|
export function Form() {
|
||||||
|
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 = useTimers(state => state.add);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (totalSeconds === 0) return;
|
||||||
|
|
||||||
|
add({
|
||||||
|
name,
|
||||||
|
total: totalSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
setName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
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
src/components/tools/timer/form/index.ts
Normal file
1
src/components/tools/timer/form/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Form } from './form';
|
||||||
1
src/components/tools/timer/index.ts
Normal file
1
src/components/tools/timer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Timer } from './timer';
|
||||||
15
src/components/tools/timer/timer.tsx
Normal file
15
src/components/tools/timer/timer.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Container } from '@/components/container';
|
||||||
|
import { Timers } from './timers';
|
||||||
|
import { Form } from './form';
|
||||||
|
import { StoreConsumer } from '@/components/store-consumer';
|
||||||
|
|
||||||
|
export function Timer() {
|
||||||
|
return (
|
||||||
|
<Container tight>
|
||||||
|
<StoreConsumer>
|
||||||
|
<Form />
|
||||||
|
<Timers />
|
||||||
|
</StoreConsumer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/tools/timer/timers/index.ts
Normal file
1
src/components/tools/timer/timers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Timers } from './timers';
|
||||||
1
src/components/tools/timer/timers/notice/index.ts
Normal file
1
src/components/tools/timer/timers/notice/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Notice } from './notice';
|
||||||
10
src/components/tools/timer/timers/notice/notice.module.css
Normal file
10
src/components/tools/timer/timers/notice/notice.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
10
src/components/tools/timer/timers/notice/notice.tsx
Normal file
10
src/components/tools/timer/timers/notice/notice.tsx
Normal file
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/tools/timer/timers/timer/index.ts
Normal file
1
src/components/tools/timer/timers/timer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Timer } from './timer';
|
||||||
126
src/components/tools/timer/timers/timer/timer.module.css
Normal file
126
src/components/tools/timer/timers/timer/timer.module.css
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
.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;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/components/tools/timer/timers/timer/timer.tsx
Normal file
212
src/components/tools/timer/timers/timer/timer.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useRef, useMemo, useState, useEffect } from 'react';
|
||||||
|
import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { Toolbar } from './toolbar';
|
||||||
|
|
||||||
|
import { useTimers } from '@/stores/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 { first, last, name, spent, total } = useTimers(state =>
|
||||||
|
state.getTimer(id),
|
||||||
|
);
|
||||||
|
const tick = useTimers(state => state.tick);
|
||||||
|
const rename = useTimers(state => state.rename);
|
||||||
|
const reset = useTimers(state => state.reset);
|
||||||
|
const deleteTimer = useTimers(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.');
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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
src/components/tools/timer/timers/timer/toolbar/index.ts
Normal file
1
src/components/tools/timer/timers/timer/toolbar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Toolbar } from './toolbar';
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/components/tools/timer/timers/timer/toolbar/toolbar.tsx
Normal file
39
src/components/tools/timer/timers/timer/toolbar/toolbar.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
|
||||||
|
|
||||||
|
import { useTimers } from '@/stores/timers';
|
||||||
|
|
||||||
|
import styles from './toolbar.module.css';
|
||||||
|
|
||||||
|
interface ToolbarProps {
|
||||||
|
first: boolean;
|
||||||
|
id: string;
|
||||||
|
last: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toolbar({ first, id, last }: ToolbarProps) {
|
||||||
|
const moveUp = useTimers(state => state.moveUp);
|
||||||
|
const moveDown = useTimers(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/tools/timer/timers/timers.module.css
Normal file
27
src/components/tools/timer/timers/timers.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/components/tools/timer/timers/timers.tsx
Normal file
46
src/components/tools/timer/timers/timers.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
||||||
|
|
||||||
|
import { Timer } from './timer';
|
||||||
|
import { Notice } from './notice';
|
||||||
|
|
||||||
|
import { useTimers } from '@/stores/timers';
|
||||||
|
|
||||||
|
import styles from './timers.module.css';
|
||||||
|
|
||||||
|
export function Timers() {
|
||||||
|
const [animationParent] = useAutoAnimate();
|
||||||
|
const [animationList] = useAutoAnimate();
|
||||||
|
|
||||||
|
const timers = useTimers(state => state.timers);
|
||||||
|
const spent = useTimers(state => state.spent());
|
||||||
|
const total = useTimers(state => state.total());
|
||||||
|
|
||||||
|
const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]);
|
||||||
|
const totalMinutes = useMemo(() => Math.floor(total / 60), [total]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={animationParent}>
|
||||||
|
{timers.length > 0 ? (
|
||||||
|
<div className={styles.timers} ref={animationList}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/hooks/use-alarm.ts
Normal file
20
src/hooks/use-alarm.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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 });
|
||||||
|
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;
|
||||||
|
}
|
||||||
21
src/pages/tools/countdown-timer.astro
Normal file
21
src/pages/tools/countdown-timer.astro
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
import Layout from '@/layouts/layout.astro';
|
||||||
|
|
||||||
|
import Donate from '@/components/donate.astro';
|
||||||
|
import Hero from '@/components/tools/hero.astro';
|
||||||
|
import { Timer } from '@/components/tools/timer';
|
||||||
|
import Footer from '@/components/footer.astro';
|
||||||
|
import About from '@/components/tools/about.astro';
|
||||||
|
import Source from '@/components/source.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Online Countdown Timer — Moodist">
|
||||||
|
<Donate />
|
||||||
|
<Hero desc="Distraction-free countdown timer." title="Countdown Timer" />
|
||||||
|
<Timer client:load />
|
||||||
|
<About
|
||||||
|
text="Stay on track with our simple countdown timer. Whether you're timing a task, workout, or event, it helps you manage your time effectively with a clear, easy-to-use interface."
|
||||||
|
/>
|
||||||
|
<Source />
|
||||||
|
<Footer />
|
||||||
|
</Layout>
|
||||||
19
src/stores/alarm.ts
Normal file
19
src/stores/alarm.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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
155
src/stores/timers.ts
Normal file
155
src/stores/timers.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface Timer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
spent: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
spent: () => number;
|
||||||
|
timers: Array<Timer>;
|
||||||
|
total: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
add: (timer: { name: string; total: number }) => void;
|
||||||
|
delete: (id: string) => void;
|
||||||
|
getTimer: (id: string) => Timer & { first: boolean; last: boolean };
|
||||||
|
moveDown: (id: string) => void;
|
||||||
|
moveUp: (id: string) => void;
|
||||||
|
rename: (id: string, newName: string) => void;
|
||||||
|
reset: (id: string) => void;
|
||||||
|
tick: (id: string, amount?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTimers = create<State & Actions>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
add({ name, total }) {
|
||||||
|
set(state => ({
|
||||||
|
timers: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name,
|
||||||
|
spent: 0,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
...state.timers,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
set(state => ({
|
||||||
|
timers: state.timers.filter(timer => timer.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getTimer(id) {
|
||||||
|
const timers = get().timers;
|
||||||
|
const timer = timers.filter(timer => timer.id === id)[0];
|
||||||
|
const index = timers.indexOf(timer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...timer,
|
||||||
|
first: index === 0,
|
||||||
|
last: index === timers.length - 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
moveDown(id) {
|
||||||
|
set(state => {
|
||||||
|
const index = state.timers.findIndex(timer => timer.id === id);
|
||||||
|
|
||||||
|
if (index < state.timers.length - 1) {
|
||||||
|
const newTimers = [...state.timers];
|
||||||
|
|
||||||
|
[newTimers[index + 1], newTimers[index]] = [
|
||||||
|
newTimers[index],
|
||||||
|
newTimers[index + 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
return { timers: newTimers };
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
moveUp(id) {
|
||||||
|
set(state => {
|
||||||
|
const index = state.timers.findIndex(timer => timer.id === id);
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
const newTimers = [...state.timers];
|
||||||
|
|
||||||
|
[newTimers[index - 1], newTimers[index]] = [
|
||||||
|
newTimers[index],
|
||||||
|
newTimers[index - 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
return { timers: newTimers };
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
rename(id, newName) {
|
||||||
|
set(state => ({
|
||||||
|
timers: state.timers.map(timer => {
|
||||||
|
if (timer.id !== id) return timer;
|
||||||
|
|
||||||
|
return { ...timer, name: newName };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(id) {
|
||||||
|
set(state => ({
|
||||||
|
timers: state.timers.map(timer => {
|
||||||
|
if (timer.id !== id) return timer;
|
||||||
|
|
||||||
|
return { ...timer, spent: 0 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
spent() {
|
||||||
|
return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(id, amount = 1) {
|
||||||
|
set(state => ({
|
||||||
|
timers: state.timers.map(timer => {
|
||||||
|
if (timer.id !== id) return timer;
|
||||||
|
|
||||||
|
const updatedSpent =
|
||||||
|
timer.spent + amount > timer.total
|
||||||
|
? timer.total
|
||||||
|
: timer.spent + amount;
|
||||||
|
|
||||||
|
return { ...timer, spent: updatedSpent };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
timers: [],
|
||||||
|
|
||||||
|
total() {
|
||||||
|
return get().timers.reduce((prev, curr) => prev + curr.total, 0);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'moodist-countdown-timers',
|
||||||
|
partialize: state => ({ timers: state.timers }),
|
||||||
|
skipHydration: true,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
version: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Loading…
Add table
Reference in a new issue