Initial commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"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;
|
||||
}
|
||||
Reference in New Issue
Block a user