mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +00:00
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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user