diff --git a/src/components/menu/items/breathing-exercise.tsx b/src/components/menu/items/breathing-exercise.tsx new file mode 100644 index 0000000..5695afb --- /dev/null +++ b/src/components/menu/items/breathing-exercise.tsx @@ -0,0 +1,18 @@ +import { IoMdFlower } from 'react-icons/io/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 dda2e17..3767bbe 100644 --- a/src/components/menu/items/index.ts +++ b/src/components/menu/items/index.ts @@ -5,3 +5,4 @@ export { Source as SourceItem } from './source'; export { Presets as PresetsItem } from './presets'; export { Shortcuts as ShortcutsItem } from './shortcuts'; export { SleepTimer as SleepTimerItem } from './sleep-timer'; +export { BreathingExercise as BreathingExerciseItem } from './breathing-exercise'; diff --git a/src/components/menu/menu.tsx b/src/components/menu/menu.tsx index 64c741a..2ee9da7 100644 --- a/src/components/menu/menu.tsx +++ b/src/components/menu/menu.tsx @@ -12,12 +12,14 @@ 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 { BreathingExerciseModal } from '../modals/breathing'; import { fade, mix, slideY } from '@/lib/motion'; import { useSoundStore } from '@/stores/sound'; @@ -32,6 +34,7 @@ export function Menu() { const initial = useMemo( () => ({ + breathing: false, presets: false, shareLink: false, shortcuts: false, @@ -61,6 +64,7 @@ export function Menu() { useHotkeys('shift+m', () => setIsOpen(prev => !prev)); useHotkeys('shift+p', () => open('presets')); useHotkeys('shift+h', () => open('shortcuts')); + useHotkeys('shift+b', () => open('breathing')); useHotkeys('shift+s', () => open('shareLink'), { enabled: !noSelected }); useHotkeys('shift+t', () => open('sleepTimer')); @@ -99,6 +103,7 @@ export function Menu() { open('shareLink')} /> open('sleepTimer')} /> + open('breathing')} /> open('shortcuts')} /> @@ -118,6 +123,10 @@ export function Menu() { show={modals.shareLink} onClose={() => close('shareLink')} /> + close('breathing')} + /> close('shortcuts')} diff --git a/src/components/modals/breathing/breathing.module.css b/src/components/modals/breathing/breathing.module.css new file mode 100644 index 0000000..fdbd99d --- /dev/null +++ b/src/components/modals/breathing/breathing.module.css @@ -0,0 +1 @@ +/* WIP */ diff --git a/src/components/modals/breathing/breathing.tsx b/src/components/modals/breathing/breathing.tsx new file mode 100644 index 0000000..6acd11b --- /dev/null +++ b/src/components/modals/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 BreathingExerciseModal({ onClose, show }: TimerProps) { + return ( + +

Breathing Exercise

+ +
+ ); +} diff --git a/src/components/modals/breathing/exercise/exercise.module.css b/src/components/modals/breathing/exercise/exercise.module.css new file mode 100644 index 0000000..05b9baa --- /dev/null +++ b/src/components/modals/breathing/exercise/exercise.module.css @@ -0,0 +1,47 @@ +.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( + var(--color-neutral-50), + 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/modals/breathing/exercise/exercise.tsx b/src/components/modals/breathing/exercise/exercise.tsx new file mode 100644 index 0000000..14e07fb --- /dev/null +++ b/src/components/modals/breathing/exercise/exercise.tsx @@ -0,0 +1,109 @@ +import { useState, useEffect, useMemo, useCallback } 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'; + +const EXERCISE_PHASES: Record = { + '4-7-8 Breathing': ['inhale', 'holdInhale', 'exhale'], + 'Box Breathing': ['inhale', 'holdInhale', 'exhale', 'holdExhale'], + 'Resonant Breathing': ['inhale', 'exhale'], +}; + +const EXERCISE_DURATIONS: Record>> = { + '4-7-8 Breathing': { exhale: 8, holdInhale: 7, inhale: 4 }, + 'Box Breathing': { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 }, + 'Resonant Breathing': { exhale: 5, inhale: 5 }, // No holdExhale +}; + +const PHASE_LABELS: Record = { + exhale: 'Exhale', + holdExhale: 'Hold', + holdInhale: 'Hold', + inhale: 'Inhale', +}; + +export function Exercise() { + const [selectedExercise, setSelectedExercise] = + useState('4-7-8 Breathing'); + const [phaseIndex, setPhaseIndex] = useState(0); + + const phases = useMemo( + () => EXERCISE_PHASES[selectedExercise], + [selectedExercise], + ); + const durations = useMemo( + () => EXERCISE_DURATIONS[selectedExercise], + [selectedExercise], + ); + + const currentPhase = phases[phaseIndex]; + + const animationVariants = useMemo( + () => ({ + exhale: { + transform: 'translate(-50%, -50%) scale(1)', + transition: { duration: durations.exhale }, + }, + holdExhale: { + transform: 'translate(-50%, -50%) scale(1)', + transition: { duration: durations.holdExhale }, + }, + holdInhale: { + transform: 'translate(-50%, -50%) scale(1.5)', + transition: { duration: durations.holdInhale }, + }, + inhale: { + transform: 'translate(-50%, -50%) scale(1.5)', + transition: { duration: durations.inhale }, + }, + }), + [durations], + ); + + const resetExercise = useCallback(() => { + setPhaseIndex(0); + }, []); + + const updatePhase = useCallback(() => { + setPhaseIndex(prevIndex => (prevIndex + 1) % phases.length); + }, [phases.length]); + + useEffect(() => { + resetExercise(); + }, [selectedExercise, resetExercise]); + + useEffect(() => { + const intervalDuration = (durations[currentPhase] || 4) * 1000; + const interval = setInterval(updatePhase, intervalDuration); + + return () => clearInterval(interval); + }, [currentPhase, durations, updatePhase]); + + return ( + <> +
+ +

{PHASE_LABELS[currentPhase]}

+
+ + + + ); +} diff --git a/src/components/modals/breathing/exercise/index.ts b/src/components/modals/breathing/exercise/index.ts new file mode 100644 index 0000000..881062d --- /dev/null +++ b/src/components/modals/breathing/exercise/index.ts @@ -0,0 +1 @@ +export { Exercise } from './exercise'; diff --git a/src/components/modals/breathing/index.ts b/src/components/modals/breathing/index.ts new file mode 100644 index 0000000..1433198 --- /dev/null +++ b/src/components/modals/breathing/index.ts @@ -0,0 +1 @@ +export { BreathingExerciseModal } from './breathing';