mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user