keep trying

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 14:49:23 +08:00
parent 69ca47e00a
commit e9b99f8243
12 changed files with 576 additions and 16 deletions
@@ -82,6 +82,7 @@ export function TableDashboard({
}
function handleStatClick(stat) {
if (stat.onClick) return stat.onClick();
if (!stat.filterId) return;
toggleFilter(stat.filterId, stat.filterValue);
}
@@ -106,7 +107,7 @@ export function TableDashboard({
label={mapping.label ?? s.label}
value={s.value}
icon={mapping.icon}
onClick={s.filterId ? () => handleStatClick(s) : undefined}
onClick={s.filterId || s.onClick ? () => handleStatClick(s) : undefined}
className={active ? "ring-2 ring-primary border-primary" : ""}
/>
);
@@ -0,0 +1,102 @@
// components/generic/UploadProgressToast.jsx
//
// Floating widget mounted once in AdminLayout (outside the router Outlet's
// unmount cycle) so it keeps showing asset-upload progress no matter what
// admin page you navigate to mid-upload. State comes from UploadQueueContext,
// which lives above the router for the same reason.
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, retryJob } = useUploadQueue();
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")}
>
View upload details
</button>
</div>
)}
</div>
);
}