mirror of
https://github.com/remvze/moodist.git
synced 2025-12-17 08:54:13 +00:00
feat: add breathing exercises tool
This commit is contained in:
parent
f526f97908
commit
27f25785e1
6 changed files with 279 additions and 0 deletions
33
src/components/tools/about.astro
Normal file
33
src/components/tools/about.astro
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
import { Container } from '../container';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<Container tight>
|
||||||
|
<h2 class="title">About this tool</h2>
|
||||||
|
<p class="text">{text}</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about {
|
||||||
|
margin-top: 75px;
|
||||||
|
|
||||||
|
& .title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
src/components/tools/breathing/breathing.module.css
Normal file
47
src/components/tools/breathing/breathing.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
124
src/components/tools/breathing/breathing.tsx
Normal file
124
src/components/tools/breathing/breathing.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { Container } from '@/components/container';
|
||||||
|
|
||||||
|
import styles from './breathing.module.css';
|
||||||
|
|
||||||
|
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
|
||||||
|
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
|
||||||
|
|
||||||
|
export function Breathing() {
|
||||||
|
const [selectedExercise, setSelectedExercise] =
|
||||||
|
useState<Exercise>('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<Phase>('inhale');
|
||||||
|
const [durations, setDurations] = useState(
|
||||||
|
getAnimationDurations(selectedExercise),
|
||||||
|
);
|
||||||
|
|
||||||
|
const animationVariants = {
|
||||||
|
exhale: {
|
||||||
|
transform: 'translate(-50%, -50%) scale(1)',
|
||||||
|
transition: { duration: durations.exhale },
|
||||||
|
},
|
||||||
|
holdExhale: {
|
||||||
|
transform: 'translate(-50%, -50%) scale(1)',
|
||||||
|
transition: { duration: durations.holdExhale || 4 },
|
||||||
|
},
|
||||||
|
holdInhale: {
|
||||||
|
transform: 'translate(-50%, -50%) scale(1.5)',
|
||||||
|
transition: { duration: durations.holdInhale || 4 },
|
||||||
|
},
|
||||||
|
inhale: {
|
||||||
|
transform: 'translate(-50%, -50%) 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 (
|
||||||
|
<Container>
|
||||||
|
<div className={styles.exercise}>
|
||||||
|
<motion.div
|
||||||
|
animate={phase}
|
||||||
|
className={styles.circle}
|
||||||
|
key={selectedExercise}
|
||||||
|
variants={animationVariants}
|
||||||
|
/>
|
||||||
|
<p className={styles.phase}>{getLabel(phase)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className={styles.selectBox}
|
||||||
|
value={selectedExercise}
|
||||||
|
onChange={e => setSelectedExercise(e.target.value as Exercise)}
|
||||||
|
>
|
||||||
|
<option value="Box Breathing">Box Breathing</option>
|
||||||
|
<option value="Resonant Breathing">Resonant Breathing</option>
|
||||||
|
<option value="4-7-8 Breathing">4-7-8 Breathing</option>
|
||||||
|
</select>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/tools/breathing/index.ts
Normal file
1
src/components/tools/breathing/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Breathing } from './breathing';
|
||||||
55
src/components/tools/hero.astro
Normal file
55
src/components/tools/hero.astro
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
import { Container } from '../container';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
desc: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { desc, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<Container>
|
||||||
|
<img alt="Moodist Logo" class="logo" src="/logo.svg" />
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p class="desc">{desc}</p>
|
||||||
|
<p class="moodist">Part of <a href="/">Moodist</a></p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
padding: 75px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& .logo {
|
||||||
|
display: block;
|
||||||
|
width: 45px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moodist {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
|
||||||
|
& a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/pages/tools/breathing-exercises.astro
Normal file
19
src/pages/tools/breathing-exercises.astro
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
import Layout from '@/layouts/layout.astro';
|
||||||
|
|
||||||
|
import Donate from '@/components/donate.astro';
|
||||||
|
import Hero from '@/components/tools/hero.astro';
|
||||||
|
import Footer from '@/components/footer.astro';
|
||||||
|
import About from '@/components/tools/about.astro';
|
||||||
|
import { Breathing } from '@/components/tools/breathing';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Breathing Exercises Tool — Moodist">
|
||||||
|
<Donate />
|
||||||
|
<Hero desc="Simple breathing exercises." title="Breathing Exercises" />
|
||||||
|
<Breathing client:load />
|
||||||
|
<About
|
||||||
|
text="Experience calm and focus with our simple breathing exercise tool. Designed to guide your breath, it helps reduce stress, enhance relaxation, and improve mindfulness in just a few minutes a day."
|
||||||
|
/>
|
||||||
|
<Footer />
|
||||||
|
</Layout>
|
||||||
Loading…
Add table
Reference in a new issue