mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
git-subtree-dir: apps/web git-subtree-mainline:eaa6d2276agit-subtree-split:459af9d46a
110 lines
5.3 KiB
React
110 lines
5.3 KiB
React
// components/generic/UploadProgressToast.jsx
|
|
//
|
|
// Floating widget mounted once in AdminLayout (outside the router Outlet's
|
|
// unmount cycle) so it keeps showing Add Assets Bulk's upload progress no
|
|
// matter what admin page you navigate to mid-batch. State comes from
|
|
// UploadQueueContext, which lives above the router for the same reason.
|
|
//
|
|
// Only renders "bulk"-sourced jobs — Add File (single) uploads share the
|
|
// same underlying queue (so they too survive navigation) but get their own
|
|
// SingleUploadToast instead of being folded into this multi-file "N of M"
|
|
// widget, which was designed around an actual batch and reads oddly for a
|
|
// single file.
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ChevronUp, ChevronDown, X, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
|
|
|
import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const ACTIVE = new Set(["uploading", "queued"]);
|
|
const FAILED = new Set(["failed", "invalid"]);
|
|
|
|
export default function UploadProgressToast() {
|
|
const { jobs: allJobs, retryJob } = useUploadQueue();
|
|
const jobs = allJobs.filter((j) => j.source === "bulk");
|
|
const navigate = useNavigate();
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
const prevActiveCountRef = useRef(0);
|
|
|
|
const active = jobs.filter((j) => ACTIVE.has(j.status));
|
|
const finished = jobs.filter((j) => !ACTIVE.has(j.status));
|
|
|
|
// A fresh batch starting up should always resurface the widget, even if
|
|
// the previous one was dismissed.
|
|
useEffect(() => {
|
|
if (prevActiveCountRef.current === 0 && active.length > 0) setDismissed(false);
|
|
prevActiveCountRef.current = active.length;
|
|
}, [active.length]);
|
|
|
|
if (dismissed || jobs.length === 0) return null;
|
|
|
|
const aggregatePct = active.length
|
|
? Math.round(active.reduce((sum, j) => sum + j.progress, 0) / active.length)
|
|
: 100;
|
|
|
|
const failedCount = finished.filter((j) => FAILED.has(j.status)).length;
|
|
|
|
const label = active.length
|
|
? `Uploading ${active.length} file${active.length === 1 ? "" : "s"}… ${aggregatePct}%`
|
|
: failedCount
|
|
? `${finished.length - failedCount} of ${finished.length} file${finished.length === 1 ? "" : "s"} uploaded`
|
|
: `${finished.length} file${finished.length === 1 ? "" : "s"} uploaded`;
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-[60] w-80 rounded-lg border bg-card shadow-lg overflow-hidden">
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer select-none"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
{active.length > 0
|
|
? <Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
|
: failedCount
|
|
? <XCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
: <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />}
|
|
<span className="text-sm font-medium flex-1 truncate">{label}</span>
|
|
<Button type="button" variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}>
|
|
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button
|
|
type="button" variant="ghost" size="icon" className="h-6 w-6"
|
|
disabled={active.length > 0}
|
|
onClick={(e) => { e.stopPropagation(); setDismissed(true); }}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{active.length > 0 && <Progress value={aggregatePct} className="h-1 rounded-none" />}
|
|
|
|
{expanded && (
|
|
<div className="max-h-64 overflow-y-auto border-t divide-y">
|
|
{jobs.map((job) => (
|
|
<div key={job.id} className="flex items-center gap-2 px-3 py-2 text-xs">
|
|
{job.status === "uploaded" && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
|
|
{FAILED.has(job.status) && <XCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
|
|
{ACTIVE.has(job.status) && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />}
|
|
<span className="flex-1 truncate">{job.name}</span>
|
|
{job.status === "failed" && (
|
|
<button type="button" className="text-primary hover:underline shrink-0" onClick={() => retryJob(job.id)}>
|
|
Retry
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="w-full text-xs text-primary hover:underline py-2"
|
|
onClick={() => navigate("/admin/assets/add/bulk")}
|
|
>
|
|
View upload details
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|