Spaces:
Running
Running
| import { useState, useEffect, useRef } from 'react' | |
| const PHASES = [ | |
| { label: 'Inhale', duration: 4, scale: 1.55 }, | |
| { label: 'Hold', duration: 4, scale: 1.55 }, | |
| { label: 'Exhale', duration: 6, scale: 1.0 }, | |
| { label: 'Hold', duration: 2, scale: 1.0 }, | |
| ] | |
| export default function DeepBreathWidget() { | |
| const [running, setRunning] = useState(false) | |
| const [phaseIdx, setPhaseIdx] = useState(0) | |
| const [tick, setTick] = useState(PHASES[0].duration) | |
| const [breaths, setBreaths] = useState(0) | |
| const intervalRef = useRef(null) | |
| function stop() { | |
| clearInterval(intervalRef.current) | |
| setRunning(false) | |
| setPhaseIdx(0) | |
| setTick(PHASES[0].duration) | |
| } | |
| function start() { | |
| setRunning(true) | |
| setPhaseIdx(0) | |
| setTick(PHASES[0].duration) | |
| } | |
| useEffect(() => { | |
| if (!running) return | |
| intervalRef.current = setInterval(() => { | |
| setTick(t => { | |
| if (t > 1) return t - 1 | |
| setPhaseIdx(pi => { | |
| const next = (pi + 1) % PHASES.length | |
| if (next === 0) setBreaths(b => b + 1) | |
| setTick(PHASES[next].duration) | |
| return next | |
| }) | |
| return PHASES[0].duration | |
| }) | |
| }, 1000) | |
| return () => clearInterval(intervalRef.current) | |
| }, [running]) | |
| const phase = PHASES[phaseIdx] | |
| const pct = running ? (tick / phase.duration) : 1 | |
| return ( | |
| <div className="dbw"> | |
| <div className="dbw-orb-wrap"> | |
| <div className={`dbw-ring dbw-ring-1 ${running ? `dbw-ring--${phaseIdx}` : ''}`} /> | |
| <div className={`dbw-ring dbw-ring-2 ${running ? `dbw-ring--${phaseIdx}` : ''}`} /> | |
| <div | |
| className="dbw-orb" | |
| style={{ | |
| transform: running ? `scale(${phase.scale})` : 'scale(1)', | |
| transition: phaseIdx === 0 | |
| ? `transform ${phase.duration}s cubic-bezier(.4,0,.2,1)` | |
| : phaseIdx === 2 | |
| ? `transform ${phase.duration}s cubic-bezier(.4,0,.2,1)` | |
| : 'none', | |
| }} | |
| > | |
| <img src="/logo.svg" alt="" className="dbw-logo" /> | |
| </div> | |
| {running && ( | |
| <svg className="dbw-arc" viewBox="0 0 120 120"> | |
| <circle cx="60" cy="60" r="54" fill="none" stroke="rgba(255,255,255,.08)" strokeWidth="4"/> | |
| <circle | |
| cx="60" cy="60" r="54" | |
| fill="none" | |
| stroke="url(#arcGrad)" | |
| strokeWidth="4" | |
| strokeLinecap="round" | |
| strokeDasharray={`${2 * Math.PI * 54}`} | |
| strokeDashoffset={`${2 * Math.PI * 54 * (1 - pct)}`} | |
| transform="rotate(-90 60 60)" | |
| /> | |
| <defs> | |
| <linearGradient id="arcGrad" x1="0" y1="0" x2="1" y2="1"> | |
| <stop offset="0%" stopColor="#FF4A26"/> | |
| <stop offset="100%" stopColor="#7B4ABE"/> | |
| </linearGradient> | |
| </defs> | |
| </svg> | |
| )} | |
| </div> | |
| <div className="dbw-info"> | |
| <div className="dbw-phase">{running ? phase.label : 'Box Breathing'}</div> | |
| <div className="dbw-tick">{running ? tick : '—'}</div> | |
| <div className="dbw-counter"> | |
| {breaths > 0 || running | |
| ? `${breaths} breath${breaths !== 1 ? 's' : ''} completed` | |
| : 'Tap start to begin'} | |
| </div> | |
| </div> | |
| <div className="dbw-controls"> | |
| {!running | |
| ? <button className="dbw-btn dbw-btn--start" onClick={start}>▶ Start</button> | |
| : <button className="dbw-btn dbw-btn--stop" onClick={stop}>■ Stop</button> | |
| } | |
| {!running && breaths > 0 && ( | |
| <button className="dbw-btn dbw-btn--reset" onClick={() => setBreaths(0)}>Reset</button> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |