volfiros commited on
Commit
0f88060
·
1 Parent(s): 7ed491f

feat: add measurement UI components and measure page with phase machine

Browse files
frontend/src/components/BPMDisplay.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion, AnimatePresence } from "framer-motion";
2
+ import { type MeasurementStatus } from "../lib/constants";
3
+ import { useState, useEffect, useRef } from "react";
4
+
5
+ interface BPMDisplayProps {
6
+ bpm: number;
7
+ status: MeasurementStatus;
8
+ }
9
+
10
+ function useStableBpm(bpm: number): number {
11
+ const [stable, setStable] = useState(0);
12
+ const pendingRef = useRef<number | null>(null);
13
+
14
+ useEffect(() => {
15
+ const rounded = Math.round(bpm);
16
+ if (rounded === stable) {
17
+ pendingRef.current = null;
18
+ return;
19
+ }
20
+ if (pendingRef.current === rounded) {
21
+ setStable(rounded);
22
+ pendingRef.current = null;
23
+ } else {
24
+ pendingRef.current = rounded;
25
+ }
26
+ }, [bpm, stable]);
27
+
28
+ return stable;
29
+ }
30
+
31
+ export default function BPMDisplay({ bpm, status }: BPMDisplayProps) {
32
+ const isActive = status === "measuring" || status === "calibrating";
33
+ const displayedBpm = useStableBpm(bpm);
34
+
35
+ return (
36
+ <div className="flex flex-col items-center">
37
+ <AnimatePresence mode="popLayout">
38
+ <motion.div
39
+ key={displayedBpm}
40
+ initial={{ opacity: 0, scale: 0.8, y: 10 }}
41
+ animate={{ opacity: 1, scale: 1, y: 0 }}
42
+ exit={{ opacity: 0, scale: 0.8, y: -10 }}
43
+ transition={{ type: "spring", stiffness: 300, damping: 25 }}
44
+ className="text-8xl md:text-9xl font-bold leading-none"
45
+ style={{
46
+ fontFamily: "var(--font-mono)",
47
+ color: isActive ? "var(--color-accent)" : "rgba(255, 255, 255, 0.3)",
48
+ }}
49
+ >
50
+ {displayedBpm > 0 ? displayedBpm : "—"}
51
+ </motion.div>
52
+ </AnimatePresence>
53
+ <span
54
+ className="mt-2 text-lg text-text-secondary uppercase tracking-widest"
55
+ style={{ fontFamily: "var(--font-mono)" }}
56
+ >
57
+ BPM
58
+ </span>
59
+ </div>
60
+ );
61
+ }
frontend/src/components/InfoDialog.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Dialog from "@radix-ui/react-dialog";
2
+ import { Info } from "lucide-react";
3
+
4
+ const TIPS = [
5
+ "Stay still — motion artifacts are the #1 enemy",
6
+ "Face the camera directly — consistent ROI = stable signal",
7
+ "Avoid backlighting — don't sit with a bright window behind you",
8
+ "Decent indoor lighting — not too dim, not flickering fluorescent",
9
+ "Clean forehead — hair, glasses, or glare can distort the reading",
10
+ "Wait for calibration — 10-15 seconds for accurate results",
11
+ ];
12
+
13
+ export default function InfoDialog() {
14
+ return (
15
+ <Dialog.Root>
16
+ <Dialog.Trigger asChild>
17
+ <button
18
+ className="fixed bottom-6 right-6 p-2 rounded-full bg-bg-card/60 text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
19
+ style={{ zIndex: 10 }}
20
+ aria-label="Tips for accurate reading"
21
+ >
22
+ <Info size={20} />
23
+ </button>
24
+ </Dialog.Trigger>
25
+ <Dialog.Portal>
26
+ <Dialog.Overlay className="fixed inset-0 bg-black/60" style={{ zIndex: 50 }} />
27
+ <Dialog.Content
28
+ className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-[24px] p-6"
29
+ style={{
30
+ zIndex: 51,
31
+ background: "rgba(0, 0, 0, 0.75)",
32
+ backdropFilter: "blur(12px)",
33
+ border: "1px solid rgba(255, 255, 255, 0.15)",
34
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
35
+ }}
36
+ >
37
+ <Dialog.Title
38
+ className="text-lg font-semibold text-text mb-4"
39
+ style={{ fontFamily: "var(--font-sans)" }}
40
+ >
41
+ Tips for Accurate Reading
42
+ </Dialog.Title>
43
+ <ul className="space-y-3">
44
+ {TIPS.map((tip, i) => (
45
+ <li key={i} className="flex gap-3 text-sm text-text-secondary">
46
+ <span className="text-accent mt-0.5">•</span>
47
+ <span>{tip}</span>
48
+ </li>
49
+ ))}
50
+ </ul>
51
+ <Dialog.Close asChild>
52
+ <button
53
+ className="mt-6 w-full py-2 rounded-full text-sm cursor-pointer"
54
+ style={{ background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", color: "rgba(255, 255, 255, 0.7)", fontFamily: "var(--font-sans)" }}
55
+ >
56
+ Got it
57
+ </button>
58
+ </Dialog.Close>
59
+ </Dialog.Content>
60
+ </Dialog.Portal>
61
+ </Dialog.Root>
62
+ );
63
+ }
frontend/src/components/ProgressBar.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface ProgressBarProps {
4
+ estimatedSeconds: number;
5
+ }
6
+
7
+ export default function ProgressBar({ estimatedSeconds }: ProgressBarProps) {
8
+ const [progress, setProgress] = useState(0);
9
+
10
+ useEffect(() => {
11
+ setProgress(0);
12
+ const interval = setInterval(() => {
13
+ setProgress((prev) => {
14
+ if (prev >= 100) return 100;
15
+ return prev + 100 / (estimatedSeconds * 10);
16
+ });
17
+ }, 100);
18
+ return () => clearInterval(interval);
19
+ }, [estimatedSeconds]);
20
+
21
+ return (
22
+ <div className="w-64 h-1.5 rounded-full bg-bg-card overflow-hidden">
23
+ <div
24
+ className="h-full rounded-full bg-accent transition-all duration-100"
25
+ style={{ width: `${Math.min(progress, 100)}%` }}
26
+ />
27
+ </div>
28
+ );
29
+ }
frontend/src/components/PulseRing.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "framer-motion";
2
+
3
+ export default function PulseRing() {
4
+ return (
5
+ <div className="relative flex items-center justify-center w-32 h-32">
6
+ <motion.div
7
+ animate={{ scale: [1, 1.2, 1], opacity: [0.6, 0.2, 0.6] }}
8
+ transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
9
+ className="absolute w-full h-full rounded-full border-2 border-accent"
10
+ />
11
+ <motion.div
12
+ animate={{ scale: [1, 1.35, 1], opacity: [0.4, 0.1, 0.4] }}
13
+ transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
14
+ className="absolute w-full h-full rounded-full border border-accent-glow"
15
+ />
16
+ <div className="w-4 h-4 rounded-full bg-accent" />
17
+ </div>
18
+ );
19
+ }
frontend/src/components/ROIOverlay.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ROIOverlayProps {
2
+ roi: { x: number; y: number; width: number; height: number } | null;
3
+ videoWidth: number;
4
+ videoHeight: number;
5
+ }
6
+
7
+ export default function ROIOverlay({ roi, videoWidth, videoHeight }: ROIOverlayProps) {
8
+ if (!roi || videoWidth === 0 || videoHeight === 0) return null;
9
+
10
+ const vw = window.innerWidth;
11
+ const vh = window.innerHeight;
12
+ const coverScale = Math.max(vw / videoWidth, vh / videoHeight);
13
+ const offsetX = (vw - videoWidth * coverScale) / 2;
14
+ const offsetY = (vh - videoHeight * coverScale) / 2;
15
+
16
+ return (
17
+ <div
18
+ className="fixed border-2 rounded-md pointer-events-none"
19
+ style={{
20
+ left: roi.x * coverScale + offsetX,
21
+ top: roi.y * coverScale + offsetY,
22
+ width: roi.width * coverScale,
23
+ height: roi.height * coverScale,
24
+ borderColor: "rgba(16, 185, 129, 0.4)",
25
+ backgroundColor: "rgba(16, 185, 129, 0.05)",
26
+ zIndex: 1,
27
+ }}
28
+ />
29
+ );
30
+ }
frontend/src/components/StartButton.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "framer-motion";
2
+
3
+ interface StartButtonProps {
4
+ onStart: () => void;
5
+ onStop: () => void;
6
+ isMeasuring: boolean;
7
+ }
8
+
9
+ export default function StartButton({ onStart, onStop, isMeasuring }: StartButtonProps) {
10
+ if (isMeasuring) {
11
+ return (
12
+ <button
13
+ onClick={onStop}
14
+ className="fixed top-6 right-6 px-4 py-2 text-xs tracking-wide cursor-pointer"
15
+ style={{
16
+ background: "rgba(255, 255, 255, 0.03)",
17
+ border: "1px solid rgba(255, 255, 255, 0.08)",
18
+ borderRadius: "9999px",
19
+ color: "rgba(255, 255, 255, 0.5)",
20
+ fontFamily: "var(--font-mono)",
21
+ letterSpacing: "0.1em",
22
+ textTransform: "uppercase",
23
+ zIndex: 10,
24
+ transition: "all 150ms ease",
25
+ }}
26
+ >
27
+ Stop
28
+ </button>
29
+ );
30
+ }
31
+
32
+ return (
33
+ <motion.button
34
+ initial={{ scale: 0.9, opacity: 0 }}
35
+ animate={{ scale: 1, opacity: 1 }}
36
+ transition={{ type: "spring", stiffness: 200 }}
37
+ whileHover={{ scale: 1.02 }}
38
+ whileTap={{ scale: 0.98 }}
39
+ onClick={onStart}
40
+ className="px-10 py-4 bg-accent text-white font-semibold text-sm tracking-wide cursor-pointer"
41
+ style={{ fontFamily: "var(--font-sans)", borderRadius: "9999px" }}
42
+ >
43
+ Start Measurement
44
+ </motion.button>
45
+ );
46
+ }
frontend/src/components/StatusPill.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "framer-motion";
2
+ import { type MeasurementStatus } from "../lib/constants";
3
+
4
+ interface StatusPillProps {
5
+ status: MeasurementStatus;
6
+ }
7
+
8
+ const STATUS_CONFIG: Record<MeasurementStatus, { label: string; color: string; bg: string; border: string }> = {
9
+ buffering: { label: "Buffering...", color: "text-warning", bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.2)" },
10
+ calibrating: { label: "Calibrating...", color: "text-warning", bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.2)" },
11
+ measuring: { label: "Measuring", color: "text-accent", bg: "rgba(16, 185, 129, 0.08)", border: "rgba(16, 185, 129, 0.2)" },
12
+ poor_signal: { label: "Stay still", color: "text-danger", bg: "rgba(239, 68, 68, 0.08)", border: "rgba(239, 68, 68, 0.2)" },
13
+ error: { label: "Error", color: "text-danger", bg: "rgba(239, 68, 68, 0.08)", border: "rgba(239, 68, 68, 0.2)" },
14
+ };
15
+
16
+ export default function StatusPill({ status }: StatusPillProps) {
17
+ const config = STATUS_CONFIG[status] || STATUS_CONFIG.error;
18
+
19
+ return (
20
+ <motion.div
21
+ layout
22
+ className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full`}
23
+ style={{ background: config.bg, border: `1px solid ${config.border}` }}
24
+ >
25
+ <span
26
+ className="w-2 h-2 rounded-full"
27
+ style={{
28
+ backgroundColor:
29
+ status === "measuring"
30
+ ? "var(--color-accent)"
31
+ : status === "poor_signal" || status === "error"
32
+ ? "var(--color-danger)"
33
+ : "var(--color-warning)",
34
+ animation: status === "measuring" ? "pulse 1.5s ease-in-out infinite" : "none",
35
+ }}
36
+ />
37
+ <span
38
+ className={`text-sm font-medium ${config.color}`}
39
+ style={{ fontFamily: "var(--font-mono)" }}
40
+ >
41
+ {config.label}
42
+ </span>
43
+ </motion.div>
44
+ );
45
+ }
frontend/src/components/WaveformChart.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useId } from "react";
2
+ import { AreaChart, Area, YAxis, ResponsiveContainer } from "recharts";
3
+
4
+ interface WaveformChartProps {
5
+ data: number[];
6
+ }
7
+
8
+ export default function WaveformChart({ data }: WaveformChartProps) {
9
+ const gradientId = useId();
10
+ const chartData = data.map((value, index) => ({ index, value }));
11
+
12
+ return (
13
+ <div className="w-full h-32 opacity-80">
14
+ <ResponsiveContainer width="100%" height="100%">
15
+ <AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
16
+ <defs>
17
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
18
+ <stop offset="0%" stopColor="#10b981" stopOpacity={0.6} />
19
+ <stop offset="100%" stopColor="#10b981" stopOpacity={0} />
20
+ </linearGradient>
21
+ </defs>
22
+ <YAxis domain={["auto", "auto"]} hide />
23
+ <Area
24
+ type="monotone"
25
+ dataKey="value"
26
+ stroke="#10b981"
27
+ strokeWidth={1.5}
28
+ fill={`url(#${gradientId})`}
29
+ isAnimationActive={false}
30
+ />
31
+ </AreaChart>
32
+ </ResponsiveContainer>
33
+ </div>
34
+ );
35
+ }
frontend/src/components/WebcamFeed.tsx CHANGED
@@ -7,9 +7,10 @@ interface WebcamFeedProps {
7
  onFrame: (r: number, g: number, b: number) => void;
8
  onFaceDetected: (roi: { x: number; y: number; width: number; height: number } | null) => void;
9
  onCameraError: (error: string) => void;
 
10
  }
11
 
12
- export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError }: WebcamFeedProps) {
13
  const videoRef = useRef<HTMLVideoElement>(null);
14
  const canvasRef = useRef<HTMLCanvasElement>(null);
15
  const { detectFace, loading: detectorLoading } = useFaceDetection();
@@ -54,6 +55,9 @@ export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError }: W
54
 
55
  canvas.width = video.videoWidth;
56
  canvas.height = video.videoHeight;
 
 
 
57
  ctx.drawImage(video, 0, 0);
58
 
59
  const now = performance.now();
 
7
  onFrame: (r: number, g: number, b: number) => void;
8
  onFaceDetected: (roi: { x: number; y: number; width: number; height: number } | null) => void;
9
  onCameraError: (error: string) => void;
10
+ onVideoSize?: (width: number, height: number) => void;
11
  }
12
 
13
+ export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError, onVideoSize }: WebcamFeedProps) {
14
  const videoRef = useRef<HTMLVideoElement>(null);
15
  const canvasRef = useRef<HTMLCanvasElement>(null);
16
  const { detectFace, loading: detectorLoading } = useFaceDetection();
 
55
 
56
  canvas.width = video.videoWidth;
57
  canvas.height = video.videoHeight;
58
+ if (canvas.width > 0 && canvas.height > 0) {
59
+ onVideoSize?.(canvas.width, canvas.height);
60
+ }
61
  ctx.drawImage(video, 0, 0);
62
 
63
  const now = performance.now();
frontend/src/pages/MeasurePage.tsx CHANGED
@@ -1,3 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  export default function MeasurePage() {
2
- return <div>MeasurePage</div>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
 
1
+ import { useNavigate } from "react-router-dom";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import { usePulseCam } from "../hooks/usePulseCam";
4
+ import WebcamFeed from "../components/WebcamFeed";
5
+ import BPMDisplay from "../components/BPMDisplay";
6
+ import WaveformChart from "../components/WaveformChart";
7
+ import StatusPill from "../components/StatusPill";
8
+ import PulseRing from "../components/PulseRing";
9
+ import StartButton from "../components/StartButton";
10
+ import InfoDialog from "../components/InfoDialog";
11
+ import ROIOverlay from "../components/ROIOverlay";
12
+ import { useState, useCallback } from "react";
13
+
14
  export default function MeasurePage() {
15
+ const navigate = useNavigate();
16
+ const { phase, bpm, confidence, waveform, status, sendFrame, start, stop } = usePulseCam();
17
+ const [cameraError, setCameraError] = useState<string | null>(null);
18
+ const [roi, setRoi] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
19
+ const [videoSize, setVideoSize] = useState<{ w: number; h: number } | null>(null);
20
+
21
+ const handleFrame = useCallback(
22
+ (r: number, g: number, b: number) => {
23
+ sendFrame(r, g, b);
24
+ },
25
+ [sendFrame]
26
+ );
27
+
28
+ const handleFaceDetected = useCallback(
29
+ (newRoi: { x: number; y: number; width: number; height: number } | null) => {
30
+ setRoi(newRoi);
31
+ },
32
+ []
33
+ );
34
+
35
+ const handleCameraError = useCallback((error: string) => {
36
+ setCameraError(error);
37
+ }, []);
38
+
39
+ const handleVideoSize = useCallback((w: number, h: number) => {
40
+ setVideoSize((prev) => (prev && prev.w === w && prev.h === h) ? prev : { w, h });
41
+ }, []);
42
+
43
+ const isMeasuring = phase === "measuring";
44
+
45
+ return (
46
+ <div className="relative w-screen h-screen overflow-hidden bg-bg-primary">
47
+ {isMeasuring && !cameraError && (
48
+ <WebcamFeed
49
+ onFrame={handleFrame}
50
+ onFaceDetected={handleFaceDetected}
51
+ onCameraError={handleCameraError}
52
+ onVideoSize={handleVideoSize}
53
+ />
54
+ )}
55
+
56
+ {isMeasuring && roi && videoSize && (
57
+ <ROIOverlay roi={roi} videoWidth={videoSize.w} videoHeight={videoSize.h} />
58
+ )}
59
+
60
+ <AnimatePresence mode="wait">
61
+ {phase === "idle" && !cameraError && (
62
+ <motion.div
63
+ key="idle"
64
+ initial={{ opacity: 0 }}
65
+ animate={{ opacity: 1 }}
66
+ exit={{ opacity: 0 }}
67
+ className="relative z-10 flex flex-col items-center justify-center h-full"
68
+ >
69
+ <StartButton onStart={start} onStop={stop} isMeasuring={false} />
70
+ </motion.div>
71
+ )}
72
+
73
+ {phase === "checking" && (
74
+ <motion.div
75
+ key="checking"
76
+ initial={{ opacity: 0 }}
77
+ animate={{ opacity: 1 }}
78
+ exit={{ opacity: 0 }}
79
+ className="relative z-10 flex flex-col items-center justify-center h-full"
80
+ >
81
+ <PulseRing />
82
+ <p className="mt-6 text-text-secondary" style={{ fontFamily: "var(--font-mono)", fontSize: "11px", letterSpacing: "0.1em", textTransform: "uppercase" }}>
83
+ Checking server...
84
+ </p>
85
+ </motion.div>
86
+ )}
87
+
88
+ {phase === "connecting" && (
89
+ <motion.div
90
+ key="connecting"
91
+ initial={{ opacity: 0 }}
92
+ animate={{ opacity: 1 }}
93
+ exit={{ opacity: 0 }}
94
+ className="relative z-10 flex flex-col items-center justify-center h-full"
95
+ >
96
+ <PulseRing />
97
+ <p className="mt-6 text-text-secondary" style={{ fontFamily: "var(--font-mono)", fontSize: "11px", letterSpacing: "0.1em", textTransform: "uppercase" }}>
98
+ Connecting...
99
+ </p>
100
+ </motion.div>
101
+ )}
102
+
103
+ {phase === "measuring" && !cameraError && (
104
+ <motion.div
105
+ key="measuring"
106
+ initial={{ opacity: 0 }}
107
+ animate={{ opacity: 1 }}
108
+ exit={{ opacity: 0 }}
109
+ className="relative z-10 flex flex-col items-center justify-center h-full pointer-events-none"
110
+ >
111
+ <BPMDisplay bpm={bpm} status={status} />
112
+ <div className="mt-4">
113
+ <StatusPill status={status} />
114
+ </div>
115
+ {confidence > 0 && (
116
+ <p
117
+ className="mt-2"
118
+ style={{ fontFamily: "var(--font-mono)", fontSize: "10px", letterSpacing: "0.1em", color: "rgba(255, 255, 255, 0.4)" }}
119
+ >
120
+ Confidence: {Math.round(confidence * 100)}%
121
+ </p>
122
+ )}
123
+ </motion.div>
124
+ )}
125
+
126
+ {phase === "error" && (
127
+ <motion.div
128
+ key="error"
129
+ initial={{ opacity: 0 }}
130
+ animate={{ opacity: 1 }}
131
+ exit={{ opacity: 0 }}
132
+ className="relative z-10 flex flex-col items-center justify-center h-full"
133
+ >
134
+ <p className="text-danger text-lg font-medium" style={{ fontFamily: "var(--font-sans)" }}>
135
+ Something went wrong
136
+ </p>
137
+ <p className="mt-2 text-text-secondary text-sm text-center max-w-sm px-4">
138
+ The server could not be reached. Please try again.
139
+ </p>
140
+ <button
141
+ onClick={() => navigate("/")}
142
+ className="mt-6 px-6 py-2 cursor-pointer"
143
+ style={{ background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "9999px", color: "rgba(255, 255, 255, 0.7)", fontFamily: "var(--font-sans)", transition: "all 150ms ease" }}
144
+ >
145
+ Back to Home
146
+ </button>
147
+ </motion.div>
148
+ )}
149
+ </AnimatePresence>
150
+
151
+ {isMeasuring && waveform.length > 0 && (
152
+ <div className="fixed bottom-0 left-0 right-0 z-10 px-4 pb-4">
153
+ <WaveformChart data={waveform} />
154
+ </div>
155
+ )}
156
+
157
+ {isMeasuring && (
158
+ <>
159
+ <StartButton onStart={start} onStop={stop} isMeasuring={true} />
160
+ <InfoDialog />
161
+ </>
162
+ )}
163
+
164
+ <button
165
+ onClick={() => navigate("/")}
166
+ className="fixed top-6 left-6 text-sm font-semibold cursor-pointer z-10"
167
+ style={{ fontFamily: "var(--font-sans)", color: "rgba(255, 255, 255, 0.5)", transition: "all 150ms ease" }}
168
+ >
169
+ PulseCam
170
+ </button>
171
+
172
+ {cameraError && (
173
+ <div className="relative z-10 flex flex-col items-center justify-center h-full">
174
+ <p className="text-danger text-lg font-medium" style={{ fontFamily: "var(--font-sans)" }}>
175
+ Camera access required
176
+ </p>
177
+ <p className="mt-2 text-text-secondary text-sm text-center max-w-sm px-4">
178
+ {cameraError}. Please allow camera permission and try again.
179
+ </p>
180
+ <button
181
+ onClick={() => { setCameraError(null); start(); }}
182
+ className="mt-6 px-6 py-2 cursor-pointer"
183
+ style={{ background: "rgba(16, 185, 129, 0.1)", border: "1px solid rgba(16, 185, 129, 0.3)", borderRadius: "9999px", color: "#10b981", fontFamily: "var(--font-sans)", transition: "all 150ms ease" }}
184
+ >
185
+ Retry
186
+ </button>
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
  }