Mirror-Backend / frontend /src /components /UploadView.jsx
Harsh200415's picture
Remove enrollment gate, add 15-session monthly cap
edee3c4
Raw
History Blame Contribute Delete
16.2 kB
import { useState, useEffect, useRef } from "react"
import api from "../lib/api"
import Reveal from "./Reveal"
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
const PREPARE_STEPS = [
{ key: "transcribing", label: "Transcribing audio" },
{ key: "diarizing", label: "Identifying speakers" },
{ key: "detecting", label: "Detecting your voice" },
]
const FINALIZE_STEPS = [
{ key: "extracting", label: "Extracting behavioral signals" },
{ key: "scoring", label: "Scoring dimensions" },
{ key: "generating", label: "Generating AI insights" },
]
function StepProgress({ steps, currentStep, title, onCancel }) {
const currentIdx = steps.findIndex(s => s.key === currentStep)
return (
<div style={{ textAlign: "center", padding: "56px 0" }}>
<p style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px", color: "#f0eeff" }}>
{title}
</p>
<p style={{ fontSize: 13, color: "#4a4865", margin: "0 0 44px" }}>
This usually takes 1–3 minutes
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 22,
maxWidth: 300, margin: "0 auto", textAlign: "left" }}>
{steps.map((step, idx) => {
const isDone = currentIdx > idx
const isCurrent = currentIdx === idx
return (
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
<div style={{
width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
background: isDone ? G : isCurrent ? "rgba(29,78,216,0.1)" : "#151922",
border: `2px solid ${isDone ? "transparent" : isCurrent ? "#1d4ed8" : "#1e2438"}`,
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: isCurrent ? "0 0 16px rgba(29,78,216,0.4)" : "none",
transition: "all 0.3s",
}}>
{isDone && <span style={{ color: "white", fontSize: 12, fontWeight: 700 }}></span>}
{isCurrent && <div style={{ width: 8, height: 8, background: "#1d4ed8",
borderRadius: "50%" }} />}
</div>
<div>
<span style={{
fontSize: 14,
color: isDone ? "#4a4865" : isCurrent ? "#f0eeff" : "#2a2a42",
fontWeight: isCurrent ? 600 : 400,
}}>
{step.label}
</span>
{isCurrent && (
<span style={{ fontSize: 12, color: "#5b9cf6", marginLeft: 6 }}>
— in progress…
</span>
)}
</div>
</div>
)
})}
</div>
<p style={{ fontSize: 12, color: "#4a4865", marginTop: 40 }}>
You can browse History while this runs
</p>
{onCancel && (
<button onClick={onCancel}
style={{ marginTop: 12, background: "none", border: "1px solid #1e2438",
borderRadius: 6, padding: "6px 18px", fontSize: 12, color: "#4a4865",
cursor: "pointer" }}>
Cancel
</button>
)}
</div>
)
}
export default function UploadView({ onResults, onActivate }) {
const [file, setFile] = useState(null)
const [step, setStep] = useState("idle")
const [error, setError] = useState("")
const [progressStep, setProgressStep] = useState(null)
const [serverWarm, setServerWarm] = useState(true)
const [usage, setUsage] = useState(null)
useEffect(() => {
api.get("/api/usage").then(r => setUsage(r.data)).catch(() => {})
}, [])
const esRef = useRef(null)
const pollRef = useRef(null)
const finalizeStartedRef = useRef(false)
const handleFinalize = async (sessionId, confirmedSpeaker) => {
setStep("finalizing")
setError("")
setProgressStep(null)
onActivate?.()
try {
const form = new FormData()
form.append("session_id", sessionId)
form.append("confirmed_speaker", confirmedSpeaker)
const { data: { job_id } } = await api.post(
"/api/finalize/start", form,
{ headers: { "Content-Type": "multipart/form-data" } }
)
const es = new EventSource(`${API_URL}/api/finalize/${job_id}/stream`)
esRef.current = es
es.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.event === "progress") {
setProgressStep(msg.step)
} else if (msg.event === "done") {
es.close(); esRef.current = null
sessionStorage.removeItem("pending_finalize_session")
setStep("idle"); setFile(null)
api.get("/api/usage").then(r => setUsage(r.data)).catch(() => {})
onResults(msg.data)
} else if (msg.event === "error") {
es.close(); esRef.current = null
sessionStorage.removeItem("pending_finalize_session")
setError(msg.message || "Analysis failed."); setStep("idle")
}
}
es.onerror = async () => {
es.close(); esRef.current = null
try {
await new Promise(r => setTimeout(r, 1200))
const res = await api.get("/api/sessions")
const saved = res.data.find(s => s.session_id === sessionId)
if (saved) {
sessionStorage.removeItem("pending_finalize_session")
setStep("idle"); setFile(null)
onResults({
signals: saved.signals, insights: saved.insights,
dimensions: saved.dimensions || {}, filename: saved.filename,
detected_speaker: saved.detected_speaker,
speaker_confirmed: saved.speaker_confirmed,
session_id: saved.session_id,
available_speakers: saved.available_speakers || [],
})
return
}
} catch {}
sessionStorage.removeItem("pending_finalize_session")
setError("Connection lost during analysis. Please try again.")
setStep("idle")
}
} catch (e) {
sessionStorage.removeItem("pending_finalize_session")
setError(e.response?.data?.detail || "Analysis failed.")
setStep("idle")
}
}
const cancelRecovery = () => {
clearInterval(pollRef.current); pollRef.current = null
esRef.current?.close(); esRef.current = null
sessionStorage.removeItem("pending_prepare_job")
sessionStorage.removeItem("pending_finalize_session")
setStep("idle")
}
useEffect(() => {
const savedFinalize = sessionStorage.getItem("pending_finalize_session")
if (savedFinalize) {
const { session_id, detected_speaker } = JSON.parse(savedFinalize)
setStep("finalizing"); onActivate?.()
handleFinalize(session_id, detected_speaker)
return
}
const saved = sessionStorage.getItem("pending_prepare_job")
if (!saved) return
const { job_id } = JSON.parse(saved)
setStep("preparing"); onActivate?.()
api.get(`/api/prepare/${job_id}/status`)
.then(res => {
sessionStorage.removeItem("pending_prepare_job")
handleFinalize(res.data.session_id, res.data.detected_speaker || "SPEAKER_00")
})
.catch(() => {
clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const res = await api.get(`/api/prepare/${job_id}/status`)
clearInterval(pollRef.current); pollRef.current = null
sessionStorage.removeItem("pending_prepare_job")
handleFinalize(res.data.session_id, res.data.detected_speaker || "SPEAKER_00")
} catch (e) {
if (e.response?.status && e.response.status !== 404) cancelRecovery()
}
}, 10000)
})
}, [])
useEffect(() => {
return () => { esRef.current?.close(); clearInterval(pollRef.current) }
}, [])
// Cold-start warmup check — HF Spaces sleeps after inactivity
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
if (!cancelled) setServerWarm(false)
}, 3500)
api.get("/health").then(() => {
clearTimeout(timer)
if (!cancelled) setServerWarm(true)
}).catch(() => {
clearTimeout(timer)
if (!cancelled) setServerWarm(false)
})
return () => { cancelled = true; clearTimeout(timer) }
}, [])
const handlePrepare = async () => {
if (!file) return
setStep("preparing"); setError(""); setProgressStep(null)
finalizeStartedRef.current = false
try {
const form = new FormData()
form.append("audio", file)
form.append("filename", file.name)
const { data: { job_id } } = await api.post(
"/api/prepare/start", form,
{ headers: { "Content-Type": "multipart/form-data" } }
)
sessionStorage.setItem("pending_prepare_job", JSON.stringify({ job_id }))
const handlePrepareDone = (data) => {
if (finalizeStartedRef.current) return
finalizeStartedRef.current = true
clearInterval(pollRef.current); pollRef.current = null
sessionStorage.removeItem("pending_prepare_job")
sessionStorage.setItem("pending_finalize_session", JSON.stringify({
session_id: data.session_id,
detected_speaker: data.detected_speaker || "SPEAKER_00",
}))
handleFinalize(data.session_id, data.detected_speaker || "SPEAKER_00")
}
const startPolling = () => {
clearInterval(pollRef.current)
pollRef.current = setInterval(async () => {
try {
const res = await api.get(`/api/prepare/${job_id}/status`)
if (res.data.current_step) setProgressStep(res.data.current_step)
handlePrepareDone(res.data)
} catch (e) {
if (e.response?.status && e.response.status !== 404) {
clearInterval(pollRef.current); pollRef.current = null
setError("Something went wrong. Please try again."); setStep("idle")
}
}
}, 10000)
}
const es = new EventSource(`${API_URL}/api/prepare/${job_id}/stream`)
esRef.current = es
es.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.event === "progress") setProgressStep(msg.step)
else if (msg.event === "done") { es.close(); esRef.current = null; handlePrepareDone(msg.data) }
else if (msg.event === "error") {
es.close(); esRef.current = null
sessionStorage.removeItem("pending_prepare_job")
setError(msg.message || "Preparation failed."); setStep("idle")
}
}
es.onerror = async () => {
es.close(); esRef.current = null
await new Promise(r => setTimeout(r, 1500))
try {
const res = await api.get(`/api/prepare/${job_id}/status`)
if (res.data.current_step) setProgressStep(res.data.current_step)
handlePrepareDone(res.data); return
} catch {}
startPolling()
}
} catch (e) {
setError(e.response?.data?.detail || "Preparation failed."); setStep("idle")
}
}
if (step === "preparing") return (
<StepProgress steps={PREPARE_STEPS} currentStep={progressStep || "transcribing"}
title="Analyzing your conversation…" onCancel={cancelRecovery} />
)
if (step === "finalizing") return (
<StepProgress steps={FINALIZE_STEPS} currentStep={progressStep || "extracting"}
title="Building your behavioral profile…" />
)
const hasFile = !!file
return (
<Reveal>
{/* Drop zone */}
<div
onClick={() => document.getElementById("file-input").click()}
style={{
border: `2px dashed ${hasFile ? "#1d4ed8" : "#1e2438"}`,
borderRadius: 16, padding: "52px 32px",
textAlign: "center", marginBottom: 20, cursor: "pointer",
background: hasFile ? "rgba(29,78,216,0.04)" : "#151922",
transition: "all 0.2s",
boxShadow: hasFile ? "inset 0 0 40px rgba(29,78,216,0.04)" : "none",
}}>
<input id="file-input" type="file" accept="*/*"
style={{ display: "none" }}
onChange={e => setFile(e.target.files[0])} />
{hasFile ? (
<>
<div style={{ fontSize: 36, marginBottom: 12 }}>🎵</div>
<p style={{ fontWeight: 600, margin: "0 0 4px", color: "#f0eeff", fontSize: 15 }}>
{file.name}
</p>
<p style={{ fontSize: 13, color: "#8b89aa", margin: 0 }}>
{(file.size / 1024 / 1024).toFixed(1)} MB · Click to change
</p>
</>
) : (
<>
<div style={{ fontSize: 44, marginBottom: 14 }}>🎙️</div>
<p style={{ fontWeight: 600, margin: "0 0 6px", color: "#f0eeff", fontSize: 16 }}>
Drop audio or video here
</p>
<p style={{ fontSize: 13, color: "#4a4865", margin: 0 }}>
.mp3 · .wav · .m4a · .mp4 · up to 100 MB
</p>
</>
)}
</div>
{!serverWarm && (
<div style={{ background: "rgba(245,158,11,0.07)",
border: "1px solid rgba(245,158,11,0.2)",
borderRadius: 8, padding: "10px 14px", marginBottom: 14,
fontSize: 12, color: "#f59e0b", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 14 }}></span>
Server is waking up — first analysis may take a little longer than usual.
</div>
)}
{usage && (
<div style={{ marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between",
alignItems: "center", marginBottom: 6 }}>
<span style={{ fontSize: 12, color: "#4a4865" }}>
Sessions this month
</span>
<span style={{ fontSize: 12, fontWeight: 600,
color: usage.remaining === 0 ? "#f87171" : usage.remaining <= 3 ? "#fb923c" : "#4a4865" }}>
{usage.used} / {usage.limit}
</span>
</div>
<div style={{ height: 3, background: "#1e2438", borderRadius: 2, overflow: "hidden" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${Math.min((usage.used / usage.limit) * 100, 100)}%`,
background: usage.remaining === 0 ? "#f87171"
: usage.remaining <= 3 ? "#fb923c" : G,
transition: "width 0.5s ease",
}} />
</div>
{usage.remaining === 0 && (
<p style={{ fontSize: 12, color: "#f87171", margin: "6px 0 0" }}>
Limit reached — resets {usage.resets_on}
</p>
)}
</div>
)}
{error && (
<div style={{ background: "rgba(248,113,113,0.08)",
border: "1px solid rgba(248,113,113,0.25)",
borderRadius: 8, padding: 12, marginBottom: 16,
fontSize: 13, color: "#f87171" }}>
⚠️ {error}
</div>
)}
<button onClick={handlePrepare} disabled={!hasFile || usage?.remaining === 0}
className={hasFile ? "btn-grad" : ""}
style={{ width: "100%", padding: "15px 24px",
background: hasFile ? G : "#151922",
color: hasFile ? "white" : "#2a2a42",
border: hasFile ? "none" : "1px solid #1e2438",
borderRadius: 10, fontSize: 15,
cursor: hasFile ? "pointer" : "not-allowed", fontWeight: 600,
boxShadow: hasFile ? "0 0 28px rgba(29,78,216,0.3)" : "none" }}>
Analyze Conversation
</button>
<div style={{ marginTop: 14, padding: "10px 14px",
background: "rgba(29,78,216,0.06)", border: "1px solid rgba(29,78,216,0.15)",
borderRadius: 8, textAlign: "center" }}>
<p style={{ fontSize: 12, color: "#6b6a8a", margin: 0 }}>
🔒 Audio is deleted immediately after analysis. Only behavioural insights are stored.{" "}
<a href="https://harsh200415-mirror-backend.hf.space/privacy"
target="_blank" rel="noreferrer"
style={{ color: "#5b9cf6", textDecoration: "none" }}>
Privacy Policy
</a>
</p>
</div>
</Reveal>
)
}