feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
@@ -35,13 +36,19 @@ interface RunOptions {
|
||||
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
|
||||
*/
|
||||
export function useServerAction(options: UseServerActionOptions = {}) {
|
||||
const t = useTranslations("pages.admin.actions");
|
||||
const locked = useRef(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
|
||||
if (locked.current) return;
|
||||
locked.current = true;
|
||||
setFieldErrors({});
|
||||
const {
|
||||
successMessage = "Saved successfully.",
|
||||
errorMessage = "An error occurred.",
|
||||
successMessage = t("saved"),
|
||||
errorMessage = t("error"),
|
||||
redirectTo,
|
||||
onSuccess,
|
||||
onError,
|
||||
@@ -61,16 +68,24 @@ export function useServerAction(options: UseServerActionOptions = {}) {
|
||||
else router.refresh();
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || errorMessage);
|
||||
setFieldErrors(result.fieldErrors ?? {});
|
||||
toast.error(result.error || errorMessage, {
|
||||
description:
|
||||
Object.values(result.fieldErrors ?? {})
|
||||
.flat()
|
||||
.join(" · ") || undefined,
|
||||
});
|
||||
onError?.();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useServerAction] Unexpected error:", err);
|
||||
toast.error(errorMessage);
|
||||
onError?.();
|
||||
} finally {
|
||||
locked.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { run, isPending };
|
||||
return { run, isPending, fieldErrors };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
/** Protect browser close/reload and same-tab link navigation while editing. */
|
||||
export function useUnsavedChanges(dirty: boolean, message: string) {
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
function click(event: MouseEvent) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
link.hasAttribute("download") ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const target = new URL(link.href, location.href);
|
||||
if (
|
||||
target.origin === location.origin &&
|
||||
target.pathname === location.pathname &&
|
||||
target.search === location.search
|
||||
)
|
||||
return;
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
document.addEventListener("click", click, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
document.removeEventListener("click", click, true);
|
||||
};
|
||||
}, [dirty, message]);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
/** Refresh after the prior request settles; pause while the tab is hidden. */
|
||||
export function useVisiblePolling(
|
||||
refresh: () => Promise<unknown>,
|
||||
delay: number,
|
||||
) {
|
||||
const callback = useRef(refresh);
|
||||
callback.current = refresh;
|
||||
useEffect(() => {
|
||||
let stopped = false,
|
||||
running = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function tick() {
|
||||
if (stopped || running) return;
|
||||
clearTimeout(timer);
|
||||
if (document.visibilityState === "hidden") return;
|
||||
running = true;
|
||||
try {
|
||||
await callback.current();
|
||||
} finally {
|
||||
running = false;
|
||||
if (!stopped) timer = setTimeout(tick, delay);
|
||||
}
|
||||
}
|
||||
function visibility() {
|
||||
if (document.visibilityState === "visible") void tick();
|
||||
else clearTimeout(timer);
|
||||
}
|
||||
void tick();
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
}, [delay]);
|
||||
}
|
||||
Reference in new issue
Block a user