HFPRXV's picture
Add prominent animated timer and stage tracking to loading UI
390e9ae
Raw History Blame Contribute Delete
8.26 kB
import { useEffect, useMemo, useRef, useState } from "react";
const ACCEPTED_TYPES = ["video/mp4", "video/quicktime"];
const STAGES = [
{ id: 1, label: "Frame extraction with FFmpeg", minSec: 0 },
{ id: 2, label: "Visual inspection with local vision model", minSec: 8 },
{ id: 3, label: "Executive report generation", minSec: 50 },
];
const initialState = {
file: null,
isDragging: false,
isLoading: false,
status: "Drop your walkthrough video",
error: null
};
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function getCurrentStageIndex(elapsed) {
let idx = 0;
for (let i = STAGES.length - 1; i >= 0; i--) {
if (elapsed >= STAGES[i].minSec) { idx = i; break; }
}
return idx;
}
export default function App() {
const [state, setState] = useState(initialState);
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
const stageIndex = getCurrentStageIndex(elapsed);
useEffect(() => {
if (state.isLoading) {
setElapsed(0);
intervalRef.current = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [state.isLoading]);
const filename = useMemo(() => {
if (!state.file) return "No file selected";
return state.file.name;
}, [state.file]);
const onDragOver = (event) => {
event.preventDefault();
setState((prev) => ({ ...prev, isDragging: true }));
};
const onDragLeave = () => {
setState((prev) => ({ ...prev, isDragging: false }));
};
const onDrop = (event) => {
event.preventDefault();
const droppedFile = event.dataTransfer.files[0];
handleFile(droppedFile);
};
const onFileChange = (event) => {
const selectedFile = event.target.files[0];
handleFile(selectedFile);
};
const handleFile = (file) => {
if (!file) return;
if (!ACCEPTED_TYPES.includes(file.type)) {
setState((prev) => ({
...prev,
error: "Only .mp4 or .mov files are supported.",
file: null
}));
return;
}
setState((prev) => ({
...prev,
file,
error: null
}));
};
const submitFile = async () => {
if (!state.file || state.isLoading) return;
setState((prev) => ({
...prev,
isLoading: true,
status: "Uploading and analyzing...",
error: null
}));
const formData = new FormData();
formData.append("file", state.file);
try {
const response = await fetch("/api/generate-report", {
method: "POST",
body: formData
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || "Upload failed");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "autoinventory-report.pdf";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
setState((prev) => ({
...prev,
isLoading: false,
status: "Report ready for download"
}));
} catch (error) {
setState((prev) => ({
...prev,
isLoading: false,
error: error.message || "Something went wrong"
}));
}
};
return (
<div className="min-h-screen bg-ink-950 text-sand-50">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(196,123,59,0.2),_transparent_55%)]" />
<div className="relative mx-auto flex min-h-screen max-w-6xl flex-col gap-10 px-6 py-16">
<header className="space-y-4">
<p className="text-sm uppercase tracking-[0.2em] text-copper-500">
AutoInventory
</p>
<h1 className="font-display text-4xl font-semibold md:text-5xl">
Instant inventory reports from a single walkthrough
</h1>
<p className="max-w-2xl text-lg text-sand-100">
Drag and drop a one-minute property video. Our agentic pipeline extracts rooms,
fixtures, and conditions into a professional inventory report.
</p>
</header>
<section className="grid gap-6 md:grid-cols-[1.2fr_0.8fr]">
<div
className={`group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-3xl border border-dashed px-6 text-center transition ${
state.isDragging
? "border-copper-500 bg-ink-900"
: state.isLoading
? "border-copper-500/40 bg-ink-900"
: "border-ink-700 bg-ink-900/60"
}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
role="button"
tabIndex={0}
onClick={() => !state.isLoading && document.getElementById("file-input").click()}
>
{state.isLoading ? (
<>
<div className="mb-4 flex items-center gap-3">
<svg className="animate-spin h-8 w-8 text-copper-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="font-display text-4xl font-semibold text-copper-400 tabular-nums">
{formatTime(elapsed)}
</span>
</div>
<p className="text-sm text-sand-100">{state.status}</p>
</>
) : (
<>
<div className="mb-4 h-12 w-12 rounded-2xl bg-ink-700/50" />
<p className="font-display text-xl">{state.status}</p>
<p className="mt-2 text-sm text-sand-100">MP4 or MOV, up to 1 minute.</p>
</>
)}
<input
id="file-input"
type="file"
accept=".mp4,.mov"
className="hidden"
onChange={onFileChange}
/>
</div>
<div className="rounded-3xl border border-ink-700 bg-ink-900/80 p-6 shadow-lift">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-sand-100">Selected</p>
<p className="mt-2 text-lg font-semibold text-sand-50">{filename}</p>
</div>
<div className="rounded-2xl bg-ink-700/50 p-4 text-sm text-sand-100">
<p className="mb-2 font-semibold text-sand-50">Processing stages</p>
<ul className="space-y-1">
{STAGES.map((s, i) => (
<li key={s.id} className={`transition-colors ${state.isLoading && i <= stageIndex ? "text-copper-400" : i < stageIndex ? "text-sand-50" : ""}`}>
{state.isLoading && i < stageIndex ? "✓ " : state.isLoading && i === stageIndex ? "▸ " : ""}
{s.id}. {s.label}
</li>
))}
</ul>
</div>
<button
className="w-full rounded-full bg-copper-500 py-3 text-sm font-semibold text-ink-950 transition hover:bg-copper-600 disabled:cursor-not-allowed disabled:bg-ink-700"
disabled={!state.file || state.isLoading}
onClick={submitFile}
>
{state.isLoading ? `Processing... (${formatTime(elapsed)})` : "Generate report"}
</button>
{state.error && (
<p className="rounded-lg border border-red-400/40 bg-red-500/10 p-3 text-sm text-red-200">
{state.error}
</p>
)}
</div>
</div>
</section>
</div>
</div>
);
}