feat(resources): add loading toast for prune, delete, and purge operations (#426)

Show a loading notification with spinner and indeterminate progress bar
while Resource Hub operations are in progress, replacing the dead moment
between confirmation and result.
This commit is contained in:
Anso
2026-04-08 09:46:27 -04:00
committed by GitHub
parent 662bc1a210
commit f6d2199978
5 changed files with 42 additions and 12 deletions
@@ -411,6 +411,7 @@ export default function ResourcesView() {
const handlePrune = async () => {
if (!confirmPrune) return;
setIsActioning(true);
const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`);
try {
const res = await apiFetch('/system/prune/system', {
method: 'POST',
@@ -427,6 +428,7 @@ export default function ResourcesView() {
} catch {
toast.error(confirmPrune ? `Failed to prune ${confirmPrune.target}` : 'Prune failed');
} finally {
toast.dismiss(loadingId);
setIsActioning(false);
setConfirmPrune(null);
}
@@ -435,6 +437,7 @@ export default function ResourcesView() {
const handleDelete = async () => {
if (!confirmDelete) return;
setIsActioning(true);
const loadingId = toast.loading(`Deleting ${confirmDelete.type.slice(0, -1)}...`);
try {
const res = await apiFetch(`/system/${confirmDelete.type}/delete`, {
method: 'POST',
@@ -446,6 +449,7 @@ export default function ResourcesView() {
} catch {
toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`);
} finally {
toast.dismiss(loadingId);
setIsActioning(false);
setConfirmDelete(null);
}
@@ -462,6 +466,7 @@ export default function ResourcesView() {
const handlePurgeOrphans = async () => {
setIsActioning(true);
const loadingId = toast.loading('Purging unmanaged containers...');
try {
const res = await apiFetch('/system/prune/orphans', {
method: 'POST',
@@ -474,6 +479,7 @@ export default function ResourcesView() {
} catch {
toast.error('Failed to purge selected containers.');
} finally {
toast.dismiss(loadingId);
setIsActioning(false);
}
};
+5 -2
View File
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
export interface Toast {
id: string;
@@ -17,10 +17,11 @@ function notify() {
listeners.forEach((fn) => fn());
}
function addToast(type: ToastType, message: string) {
function addToast(type: ToastType, message: string): string {
const id = `toast-${++idCounter}-${Date.now()}`;
toasts = [...toasts, { id, type, message, createdAt: Date.now() }];
notify();
return id;
}
export function removeToast(id: string) {
@@ -48,4 +49,6 @@ export const toast = {
error: (message: string) => addToast('error', message),
warning: (message: string) => addToast('warning', message),
info: (message: string) => addToast('info', message),
loading: (message: string) => addToast('loading', message),
dismiss: (id: string) => removeToast(id),
};
+26 -9
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'motion/react';
import { Loader2 } from 'lucide-react';
import { useToasts, removeToast, type ToastType } from './toast-store';
/* ── Durations & Config ── */
@@ -10,6 +11,7 @@ const DURATIONS: Record<ToastType, number> = {
error: 6000,
warning: 5000,
info: 4000,
loading: Infinity,
};
const MAX_VISIBLE = 5;
@@ -73,6 +75,11 @@ const notificationConfig: Record<ToastType, {
icon: <ErrorIcon className="h-6 w-6" />,
gradient: 'from-destructive-muted to-transparent',
},
loading: {
iconColor: 'text-brand',
icon: <Loader2 className="h-6 w-6 animate-spin" strokeWidth={1.5} />,
gradient: 'from-info-muted to-transparent',
},
};
/* ── ToastItem — faithful Sera UI Notification replica ── */
@@ -89,8 +96,9 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
removeToast(id);
}, [id]);
// Auto-dismiss timer with hover pause
// Auto-dismiss timer with hover pause (loading toasts never auto-dismiss)
useEffect(() => {
if (type === 'loading') return;
if (hovered) {
if (timerRef.current) {
clearTimeout(timerRef.current);
@@ -104,7 +112,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [hovered, dismiss]);
}, [hovered, dismiss, type]);
return (
<motion.div
@@ -137,14 +145,23 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
</button>
</div>
{/* Progress bar — Sera UI style with Framer Motion */}
{/* Progress bar */}
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-success via-info to-brand"
/>
{type === 'loading' ? (
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '200%' }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
className="h-full w-1/3 bg-gradient-to-r from-transparent via-brand to-transparent"
/>
) : (
<motion.div
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
transition={{ duration: duration / 1000, ease: 'linear' }}
className="h-full bg-gradient-to-r from-success via-info to-brand"
/>
)}
</div>
</motion.div>
);