diff --git a/package-lock.json b/package-lock.json index e17d3a2..1ef4a10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3602,7 +3602,8 @@ "node_modules/@formkit/auto-animate": { "version": "0.8.2", "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": { "version": "0.11.11", diff --git a/src/components/store-consumer/store-consumer.tsx b/src/components/store-consumer/store-consumer.tsx index 101be03..77b10cc 100644 --- a/src/components/store-consumer/store-consumer.tsx +++ b/src/components/store-consumer/store-consumer.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useSoundStore } from '@/stores/sound'; import { useNoteStore } from '@/stores/note'; import { usePresetStore } from '@/stores/preset'; +import { useTimers } from '@/stores/timers'; interface StoreConsumerProps { children: React.ReactNode; @@ -13,6 +14,7 @@ export function StoreConsumer({ children }: StoreConsumerProps) { useSoundStore.persist.rehydrate(); useNoteStore.persist.rehydrate(); usePresetStore.persist.rehydrate(); + useTimers.persist.rehydrate(); }, []); return <>{children}; diff --git a/src/components/tools/timer/form/field/field.module.css b/src/components/tools/timer/form/field/field.module.css new file mode 100644 index 0000000..b505d8a --- /dev/null +++ b/src/components/tools/timer/form/field/field.module.css @@ -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; + } +} diff --git a/src/components/tools/timer/form/field/field.tsx b/src/components/tools/timer/form/field/field.tsx new file mode 100644 index 0000000..6b057a8 --- /dev/null +++ b/src/components/tools/timer/form/field/field.tsx @@ -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 ( +
+ + + {type === 'text' && ( + onChange(e.target.value)} + /> + )} + + {type === 'select' && ( + + )} +
+ ); +} diff --git a/src/components/tools/timer/form/field/index.ts b/src/components/tools/timer/form/field/index.ts new file mode 100644 index 0000000..497b3a7 --- /dev/null +++ b/src/components/tools/timer/form/field/index.ts @@ -0,0 +1 @@ +export { Field } from './field'; diff --git a/src/components/tools/timer/form/form.module.css b/src/components/tools/timer/form/form.module.css new file mode 100644 index 0000000..b6a60ee --- /dev/null +++ b/src/components/tools/timer/form/form.module.css @@ -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; +} diff --git a/src/components/tools/timer/form/form.tsx b/src/components/tools/timer/form/form.tsx new file mode 100644 index 0000000..748c3a0 --- /dev/null +++ b/src/components/tools/timer/form/form.tsx @@ -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) => { + e.preventDefault(); + + if (totalSeconds === 0) return; + + add({ + name, + total: totalSeconds, + }); + + setName(''); + }; + + return ( +
+ setName(value as string)} + /> + +
+ setHours(value as number)} + > + {Array(13) + .fill(null) + .map((_, index) => ( + + ))} + + + setMinutes(value as number)} + > + {Array(60) + .fill(null) + .map((_, index) => ( + + ))} + + + setSeconds(value as number)} + > + {Array(60) + .fill(null) + .map((_, index) => ( + + ))} + +
+ + + + ); +} diff --git a/src/components/tools/timer/form/index.ts b/src/components/tools/timer/form/index.ts new file mode 100644 index 0000000..9398c9e --- /dev/null +++ b/src/components/tools/timer/form/index.ts @@ -0,0 +1 @@ +export { Form } from './form'; diff --git a/src/components/tools/timer/index.ts b/src/components/tools/timer/index.ts new file mode 100644 index 0000000..91b3f08 --- /dev/null +++ b/src/components/tools/timer/index.ts @@ -0,0 +1 @@ +export { Timer } from './timer'; diff --git a/src/components/tools/timer/timer.tsx b/src/components/tools/timer/timer.tsx new file mode 100644 index 0000000..c7affa1 --- /dev/null +++ b/src/components/tools/timer/timer.tsx @@ -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 ( + + +
+ + + + ); +} diff --git a/src/components/tools/timer/timers/index.ts b/src/components/tools/timer/timers/index.ts new file mode 100644 index 0000000..0f9e27a --- /dev/null +++ b/src/components/tools/timer/timers/index.ts @@ -0,0 +1 @@ +export { Timers } from './timers'; diff --git a/src/components/tools/timer/timers/notice/index.ts b/src/components/tools/timer/timers/notice/index.ts new file mode 100644 index 0000000..64af78d --- /dev/null +++ b/src/components/tools/timer/timers/notice/index.ts @@ -0,0 +1 @@ +export { Notice } from './notice'; diff --git a/src/components/tools/timer/timers/notice/notice.module.css b/src/components/tools/timer/timers/notice/notice.module.css new file mode 100644 index 0000000..54c58c0 --- /dev/null +++ b/src/components/tools/timer/timers/notice/notice.module.css @@ -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; +} diff --git a/src/components/tools/timer/timers/notice/notice.tsx b/src/components/tools/timer/timers/notice/notice.tsx new file mode 100644 index 0000000..a25b74c --- /dev/null +++ b/src/components/tools/timer/timers/notice/notice.tsx @@ -0,0 +1,10 @@ +import styles from './notice.module.css'; + +export function Notice() { + return ( +

+ Please do not close this tab while timers are running, otherwise all + timers will be stopped. +

+ ); +} diff --git a/src/components/tools/timer/timers/timer/index.ts b/src/components/tools/timer/timers/timer/index.ts new file mode 100644 index 0000000..91b3f08 --- /dev/null +++ b/src/components/tools/timer/timers/timer/index.ts @@ -0,0 +1 @@ +export { Timer } from './timer'; diff --git a/src/components/tools/timer/timers/timer/timer.module.css b/src/components/tools/timer/timers/timer/timer.module.css new file mode 100644 index 0000000..a59151c --- /dev/null +++ b/src/components/tools/timer/timers/timer/timer.module.css @@ -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); + } + } +} diff --git a/src/components/tools/timer/timers/timer/timer.tsx b/src/components/tools/timer/timers/timer/timer.tsx new file mode 100644 index 0000000..516da01 --- /dev/null +++ b/src/components/tools/timer/timers/timer/timer.tsx @@ -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 | null>(null); + const lastActiveTimeRef = useRef(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 ( +
+
+
+
+
+
+ + + +
setIsReversed(prev => !prev)} + onKeyDown={() => setIsReversed(prev => !prev)} + > + {!isReversed ? ( + <> + {padNumber(hours)} + : + {padNumber(minutes)} + : + {padNumber(seconds)} + + ) : ( + <> + - + {padNumber(spentHours)} + : + {padNumber(spentMinutes)} + : + {padNumber(spentSeconds)} + + )} +
+ +
+
+ rename(id, e.target.value)} + /> + + + + +
+ + +
+
+ ); +} diff --git a/src/components/tools/timer/timers/timer/toolbar/index.ts b/src/components/tools/timer/timers/timer/toolbar/index.ts new file mode 100644 index 0000000..dc5abb1 --- /dev/null +++ b/src/components/tools/timer/timers/timer/toolbar/index.ts @@ -0,0 +1 @@ +export { Toolbar } from './toolbar'; diff --git a/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css b/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css new file mode 100644 index 0000000..473234c --- /dev/null +++ b/src/components/tools/timer/timers/timer/toolbar/toolbar.module.css @@ -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); + } + } +} diff --git a/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx b/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx new file mode 100644 index 0000000..7020ee8 --- /dev/null +++ b/src/components/tools/timer/timers/timer/toolbar/toolbar.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/components/tools/timer/timers/timers.module.css b/src/components/tools/timer/timers/timers.module.css new file mode 100644 index 0000000..e8cc2c6 --- /dev/null +++ b/src/components/tools/timer/timers/timers.module.css @@ -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); + } + } +} diff --git a/src/components/tools/timer/timers/timers.tsx b/src/components/tools/timer/timers/timers.tsx new file mode 100644 index 0000000..03ebddd --- /dev/null +++ b/src/components/tools/timer/timers/timers.tsx @@ -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 ( +
+ {timers.length > 0 ? ( +
+
+

Timers

+
+ {totalMinutes > 0 && ( +

+ {spentMinutes} / {totalMinutes} Minute + {totalMinutes !== 1 && 's'} +

+ )} +
+ + {timers.map(timer => ( + + ))} + + +
+ ) : null} +
+ ); +} diff --git a/src/hooks/use-alarm.ts b/src/hooks/use-alarm.ts new file mode 100644 index 0000000..f5ad6ca --- /dev/null +++ b/src/hooks/use-alarm.ts @@ -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; +} diff --git a/src/pages/tools/countdown-timer.astro b/src/pages/tools/countdown-timer.astro new file mode 100644 index 0000000..c10446a --- /dev/null +++ b/src/pages/tools/countdown-timer.astro @@ -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'; +--- + + + + + + + +