From fc4f52146e2142a0c711b6d6a334c0107b1e1daa Mon Sep 17 00:00:00 2001 From: MAZE Date: Mon, 1 Jul 2024 18:50:12 +0330 Subject: [PATCH] feat: add simple breathing exercise tool --- .../menu/items/breathing-exercise.tsx | 18 +++ src/components/menu/items/index.ts | 1 + src/components/menu/menu.tsx | 16 ++- .../toolbox/breathing/breathing.module.css | 1 + .../toolbox/breathing/breathing.tsx | 18 +++ .../breathing/exercise/exercise.module.css | 44 +++++++ .../toolbox/breathing/exercise/exercise.tsx | 120 ++++++++++++++++++ .../toolbox/breathing/exercise/index.ts | 1 + src/components/toolbox/breathing/index.ts | 1 + src/components/toolbox/index.ts | 1 + 10 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/components/menu/items/breathing-exercise.tsx create mode 100644 src/components/toolbox/breathing/breathing.module.css create mode 100644 src/components/toolbox/breathing/breathing.tsx create mode 100644 src/components/toolbox/breathing/exercise/exercise.module.css create mode 100644 src/components/toolbox/breathing/exercise/exercise.tsx create mode 100644 src/components/toolbox/breathing/exercise/index.ts create mode 100644 src/components/toolbox/breathing/index.ts diff --git a/src/components/menu/items/breathing-exercise.tsx b/src/components/menu/items/breathing-exercise.tsx new file mode 100644 index 0000000..6d5dce4 --- /dev/null +++ b/src/components/menu/items/breathing-exercise.tsx @@ -0,0 +1,18 @@ +import { MdOutlineTimer } from 'react-icons/md/index'; + +import { Item } from '../item'; + +interface BreathingExerciseProps { + open: () => void; +} + +export function BreathingExercise({ open }: BreathingExerciseProps) { + return ( + } + label="Breathing Exercise" + shortcut="Shift + B" + onClick={open} + /> + ); +} diff --git a/src/components/menu/items/index.ts b/src/components/menu/items/index.ts index e5c2904..08aa347 100644 --- a/src/components/menu/items/index.ts +++ b/src/components/menu/items/index.ts @@ -8,3 +8,4 @@ export { Presets as PresetsItem } from './presets'; export { Shortcuts as ShortcutsItem } from './shortcuts'; export { SleepTimer as SleepTimerItem } from './sleep-timer'; export { CountdownTimer as CountdownTimerItem } from './countdown-timer'; +export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise'; diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index 52f8f6e..2692861 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -15,13 +15,19 @@ import { PresetsItem, ShortcutsItem, SleepTimerItem, + BreathingExerciseItem, } from './items'; import { Divider } from './divider'; import { ShareLinkModal } from '@/components/modals/share-link'; import { PresetsModal } from '@/components/modals/presets'; import { ShortcutsModal } from '@/components/modals/shortcuts'; import { SleepTimerModal } from '@/components/modals/sleep-timer'; -import { Notepad, Pomodoro, CountdownTimer } from '@/components/toolbox'; +import { + Notepad, + Pomodoro, + CountdownTimer, + BreathingExercise, +} from '@/components/toolbox'; import { fade, mix, slideY } from '@/lib/motion'; import { useSoundStore } from '@/stores/sound'; @@ -36,6 +42,7 @@ export function Menu() { const initial = useMemo( () => ({ + breathingExercise: false, countdownTimer: false, notepad: false, pomodoro: false, @@ -113,6 +120,9 @@ export function Menu() { open('pomodoro')} /> open('notepad')} /> + open('breathingExercise')} + /> open('countdownTimer')} /> @@ -144,6 +154,10 @@ export function Menu() { show={modals.pomodoro} onClose={() => close('pomodoro')} /> + close('breathingExercise')} + /> close('countdownTimer')} diff --git a/src/components/toolbox/breathing/breathing.module.css b/src/components/toolbox/breathing/breathing.module.css new file mode 100644 index 0000000..fdbd99d --- /dev/null +++ b/src/components/toolbox/breathing/breathing.module.css @@ -0,0 +1 @@ +/* WIP */ diff --git a/src/components/toolbox/breathing/breathing.tsx b/src/components/toolbox/breathing/breathing.tsx new file mode 100644 index 0000000..c807a22 --- /dev/null +++ b/src/components/toolbox/breathing/breathing.tsx @@ -0,0 +1,18 @@ +import { Modal } from '@/components/modal'; +import { Exercise } from './exercise'; + +import styles from './breathing.module.css'; + +interface TimerProps { + onClose: () => void; + show: boolean; +} + +export function BreathingExercise({ onClose, show }: TimerProps) { + return ( + +

Breathing Exercise

+ +
+ ); +} diff --git a/src/components/toolbox/breathing/exercise/exercise.module.css b/src/components/toolbox/breathing/exercise/exercise.module.css new file mode 100644 index 0000000..0bfd464 --- /dev/null +++ b/src/components/toolbox/breathing/exercise/exercise.module.css @@ -0,0 +1,44 @@ +.exercise { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 75px 0; + margin-top: 12px; + background-color: var(--color-neutral-50); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; + + & .phase { + font-family: var(--font-display); + font-size: var(--font-lg); + font-weight: 600; + } + + & .circle { + position: absolute; + top: 50%; + left: 50%; + z-index: -1; + height: 55%; + aspect-ratio: 1 / 1; + background-image: radial-gradient(transparent, var(--color-neutral-100)); + border: 1px solid var(--color-neutral-200); + border-radius: 50%; + transform: translate(-50%, -50%); + } +} + +.selectBox { + width: 100%; + min-width: 0; + height: 45px; + padding: 0 12px; + margin-top: 8px; + font-size: var(--font-sm); + color: var(--color-foreground); + background-color: var(--color-neutral-100); + border: 1px solid var(--color-neutral-200); + border-radius: 8px; +} diff --git a/src/components/toolbox/breathing/exercise/exercise.tsx b/src/components/toolbox/breathing/exercise/exercise.tsx new file mode 100644 index 0000000..da06512 --- /dev/null +++ b/src/components/toolbox/breathing/exercise/exercise.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; + +import styles from './exercise.module.css'; + +type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing'; +type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale'; + +export function Exercise() { + const [selectedExercise, setSelectedExercise] = + useState('4-7-8 Breathing'); + + const getAnimationPhases = ( + exercise: Exercise, + ): Array<'inhale' | 'holdInhale' | 'exhale' | 'holdExhale'> => { + switch (exercise) { + case 'Box Breathing': + return ['inhale', 'holdInhale', 'exhale', 'holdExhale']; + case 'Resonant Breathing': + return ['inhale', 'exhale']; + case '4-7-8 Breathing': + return ['inhale', 'holdInhale', 'exhale']; + default: + return ['inhale', 'holdInhale', 'exhale', 'holdExhale']; + } + }; + + const getAnimationDurations = (exercise: Exercise) => { + switch (exercise) { + case 'Box Breathing': + return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 }; + case 'Resonant Breathing': + return { exhale: 5, inhale: 5 }; + case '4-7-8 Breathing': + return { exhale: 8, holdInhale: 7, inhale: 4 }; + default: + return { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 }; + } + }; + + const getLabel = (phase: Phase) => { + switch (phase) { + case 'inhale': + return 'Inhale'; + case 'exhale': + return 'Exhale'; + default: + return 'Hold'; + } + }; + + const [phase, setPhase] = useState('inhale'); + const [durations, setDurations] = useState( + getAnimationDurations(selectedExercise), + ); + + const animationVariants = { + exhale: { scale: 1, transition: { duration: durations.exhale } }, + holdExhale: { + scale: 1, + transition: { duration: durations.holdExhale || 4 }, + }, + holdInhale: { + scale: 1.5, + transition: { duration: durations.holdInhale || 4 }, + }, + inhale: { scale: 1.5, transition: { duration: durations.inhale } }, + }; + + useEffect(() => { + setDurations(getAnimationDurations(selectedExercise)); + }, [selectedExercise]); + + useEffect(() => { + const phases = getAnimationPhases(selectedExercise); + + let phaseIndex = 0; + + setPhase(phases[phaseIndex]); + + const interval = setInterval( + () => { + phaseIndex = (phaseIndex + 1) % phases.length; + + setPhase(phases[phaseIndex]); + }, + (durations[phases[phaseIndex]] || 4) * 1000, + ); + + return () => clearInterval(interval); + }, [selectedExercise, durations]); + + return ( + <> +
+ + `translate(-50%, -50%) ${generatedString}` + } + /> +

{getLabel(phase)}

+
+ + + + ); +} diff --git a/src/components/toolbox/breathing/exercise/index.ts b/src/components/toolbox/breathing/exercise/index.ts new file mode 100644 index 0000000..881062d --- /dev/null +++ b/src/components/toolbox/breathing/exercise/index.ts @@ -0,0 +1 @@ +export { Exercise } from './exercise'; diff --git a/src/components/toolbox/breathing/index.ts b/src/components/toolbox/breathing/index.ts new file mode 100644 index 0000000..323b544 --- /dev/null +++ b/src/components/toolbox/breathing/index.ts @@ -0,0 +1 @@ +export { BreathingExercise } from './breathing'; diff --git a/src/components/toolbox/index.ts b/src/components/toolbox/index.ts index 67924b9..f8725ad 100644 --- a/src/components/toolbox/index.ts +++ b/src/components/toolbox/index.ts @@ -1,3 +1,4 @@ export { Notepad } from './notepad'; export { Pomodoro } from './pomodoro'; export { CountdownTimer } from './countdown-timer'; +export { BreathingExercise } from './breathing';