121 lines
4.1 KiB
TypeScript
121 lines
4.1 KiB
TypeScript
"use client";
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Undo2, X } from "lucide-react";
|
|
import { restoreImages } from "@/app/actions/trash";
|
|
|
|
interface ToastState {
|
|
ids: number[];
|
|
visibleAt: number;
|
|
failed?: { message: string };
|
|
}
|
|
|
|
interface Ctx {
|
|
show: (ids: number[]) => void;
|
|
}
|
|
|
|
const ToastCtx = createContext<Ctx | null>(null);
|
|
const VISIBLE_MS = 8000;
|
|
|
|
export function UndoDeleteToastProvider({ children }: { children: React.ReactNode }) {
|
|
const [state, setState] = useState<ToastState | null>(null);
|
|
const timerRef = useRef<number | null>(null);
|
|
const router = useRouter();
|
|
const [pending, start] = useTransition();
|
|
|
|
const dismiss = useCallback(() => {
|
|
setState(null);
|
|
if (timerRef.current != null) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const show = useCallback((ids: number[]) => {
|
|
if (ids.length === 0) return;
|
|
setState({ ids, visibleAt: Date.now() });
|
|
if (timerRef.current != null) clearTimeout(timerRef.current);
|
|
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
|
|
}, []);
|
|
|
|
useEffect(() => () => { if (timerRef.current != null) clearTimeout(timerRef.current); }, []);
|
|
|
|
const undo = () => {
|
|
if (!state) return;
|
|
const ids = state.ids;
|
|
// Hide the trash-confirmation copy while the restore is in flight,
|
|
// but DON'T dismiss the toast — if restoreImages throws, the items
|
|
// are still in trash and the user has no signal. Re-surface in the
|
|
// catch with a retry affordance.
|
|
if (timerRef.current != null) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
start(async () => {
|
|
try {
|
|
await restoreImages(ids);
|
|
router.refresh();
|
|
setState(null);
|
|
} catch (e) {
|
|
setState({
|
|
ids,
|
|
visibleAt: Date.now(),
|
|
failed: { message: (e as Error).message || "Restore failed" },
|
|
});
|
|
timerRef.current = window.setTimeout(() => setState(null), VISIBLE_MS);
|
|
}
|
|
});
|
|
};
|
|
|
|
const ctx = useMemo<Ctx>(() => ({ show }), [show]);
|
|
|
|
return (
|
|
<ToastCtx.Provider value={ctx}>
|
|
{children}
|
|
{state && (
|
|
<div className="fixed bottom-[80px] left-1/2 -translate-x-1/2 z-[60]">
|
|
<div
|
|
className="rounded-2xl shadow-2xl px-4 py-2.5 flex items-center gap-3 border border-[var(--color-glass-border-strong)] backdrop-blur-2xl"
|
|
style={{ background: "color-mix(in oklch, var(--color-bg-0) 90%, transparent)" }}
|
|
>
|
|
<span className="text-sm">
|
|
{state.failed ? (
|
|
<>
|
|
<span className="text-[var(--color-red)]">Restore failed</span>
|
|
<span className="text-[var(--color-fg-dim)]"> — {state.failed.message}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="font-mono text-[var(--color-cyan)]">{state.ids.length}</span>
|
|
<span className="text-[var(--color-fg-dim)]"> moved to trash</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
<div className="w-px h-5 bg-[var(--color-glass-border)]" />
|
|
<button
|
|
onClick={undo}
|
|
disabled={pending}
|
|
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)]/15 text-[var(--color-cyan)] border border-[var(--color-cyan)]/40 hover:bg-[var(--color-cyan)]/25 disabled:opacity-50"
|
|
>
|
|
<Undo2 className="w-3.5 h-3.5" /> {state.failed ? "Retry" : "Undo"}
|
|
</button>
|
|
<button
|
|
onClick={dismiss}
|
|
aria-label="Dismiss"
|
|
className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ToastCtx.Provider>
|
|
);
|
|
}
|
|
|
|
export function useUndoDeleteToast() {
|
|
const ctx = useContext(ToastCtx);
|
|
if (!ctx) throw new Error("useUndoDeleteToast must be used within UndoDeleteToastProvider");
|
|
return ctx;
|
|
}
|