Spaces:
Sleeping
Sleeping
| import React, { useEffect, useRef, useState } from 'react'; | |
| import { X, CheckCircle, AlertCircle } from 'lucide-react'; | |
| import { useAppStore } from '../store'; | |
| import './FloatingPill.css'; | |
| /** | |
| * FloatingPill β always-on-top status indicator for long-running operations. | |
| * | |
| * Walks through a state machine (loading-model β transcribing β translating | |
| * β generating β done) with a live elapsed timer, mini progress bar, and | |
| * dismiss button. | |
| * | |
| * Reads entirely from the pillSlice in the Zustand store β any part of the | |
| * app can trigger it via `useAppStore.getState().showPill(...)`. | |
| */ | |
| function formatElapsed(ms) { | |
| const secs = Math.floor(ms / 1000); | |
| const mins = Math.floor(secs / 60); | |
| const s = secs % 60; | |
| if (mins > 0) return `${mins}:${String(s).padStart(2, '0')}`; | |
| return `${s}s`; | |
| } | |
| const STAGE_LABELS = { | |
| 'loading-model': 'π§ ', | |
| 'recording': 'ποΈ', | |
| 'transcribing': 'π', | |
| 'translating': 'π', | |
| 'generating': 'π', | |
| 'exporting': 'π¦', | |
| 'refining': 'β¨', | |
| 'done': 'β ', | |
| 'error': 'β', | |
| }; | |
| export default function FloatingPill() { | |
| const visible = useAppStore(s => s.visible); | |
| const stage = useAppStore(s => s.stage); | |
| const label = useAppStore(s => s.label); | |
| const progress = useAppStore(s => s.progress); | |
| const startedAt = useAppStore(s => s.startedAt); | |
| const error = useAppStore(s => s.error); | |
| const cancellable = useAppStore(s => s.cancellable); | |
| const dismissPill = useAppStore(s => s.dismissPill); | |
| const [elapsed, setElapsed] = useState(0); | |
| const [exiting, setExiting] = useState(false); | |
| const timerRef = useRef(null); | |
| // Elapsed timer | |
| useEffect(() => { | |
| if (!startedAt || stage === 'done' || stage === 'error' || stage === 'idle') { | |
| setElapsed(0); | |
| return; | |
| } | |
| const tick = () => setElapsed(Date.now() - startedAt); | |
| tick(); | |
| timerRef.current = setInterval(tick, 1000); | |
| return () => clearInterval(timerRef.current); | |
| }, [startedAt, stage]); | |
| // Handle dismiss with exit animation | |
| const handleDismiss = () => { | |
| setExiting(true); | |
| setTimeout(() => { | |
| setExiting(false); | |
| dismissPill(); | |
| }, 250); | |
| }; | |
| if (!visible) return null; | |
| const stageEmoji = STAGE_LABELS[stage] || 'β³'; | |
| const isDone = stage === 'done'; | |
| const isError = stage === 'error'; | |
| const isActive = !isDone && !isError && stage !== 'idle'; | |
| return ( | |
| <div | |
| className={[ | |
| 'floating-pill', | |
| exiting ? 'floating-pill--exiting' : '', | |
| isDone ? 'floating-pill--done' : '', | |
| isError ? 'floating-pill--error' : '', | |
| ].filter(Boolean).join(' ')} | |
| role="status" | |
| aria-live="polite" | |
| > | |
| {/* Stage indicator dot */} | |
| <span className={`floating-pill__dot floating-pill__dot--${stage}`} /> | |
| {/* Content */} | |
| <div className="floating-pill__content"> | |
| <span className="floating-pill__label"> | |
| {stageEmoji} {label} | |
| </span> | |
| {/* Meta row: timer + progress text */} | |
| <div className="floating-pill__meta"> | |
| {isActive && elapsed > 0 && ( | |
| <span className="floating-pill__timer">{formatElapsed(elapsed)}</span> | |
| )} | |
| {progress !== null && isActive && ( | |
| <span>{Math.round(progress)}%</span> | |
| )} | |
| {isError && error && ( | |
| <span className="floating-pill__error" title={error}>{error}</span> | |
| )} | |
| </div> | |
| {/* Mini progress bar */} | |
| {isActive && ( | |
| <div className="floating-pill__progress"> | |
| <div | |
| className={[ | |
| 'floating-pill__progress-fill', | |
| progress === null ? 'floating-pill__progress-fill--indeterminate' : '', | |
| ].filter(Boolean).join(' ')} | |
| style={progress !== null ? { width: `${progress}%` } : undefined} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Dismiss / cancel button */} | |
| <button | |
| className="floating-pill__dismiss" | |
| onClick={handleDismiss} | |
| title={cancellable ? 'Cancel' : 'Dismiss'} | |
| aria-label={cancellable ? 'Cancel operation' : 'Dismiss status'} | |
| > | |
| <X size={12} /> | |
| </button> | |
| </div> | |
| ); | |
| } | |