diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 58d9a75..1673980 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -11,7 +11,7 @@ import { Metadata } from "next"; import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; -import { canAccessAdminPanel } from "@/utils/access-control/hasPermission"; +import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts"; import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime"; @@ -29,6 +29,7 @@ import { cookies } from "next/headers"; import { IMPERSONATION_ACTIVE_COOKIE } from "@/lib/impersonation"; import { ImpersonationBanner } from "@/components/frontend/impersonation/ImpersonationBanner"; import { ShimLoader } from "@/components/frontend/shims/ShimLoader"; +import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate"; // Skip static prerendering of this layout (and all pages under (frontend)/). // The layout calls `getPayload()` at render time to resolve the current user, @@ -184,6 +185,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { )} {user && } {user && } + {process.env.NODE_ENV === "development" && user && } {user && } {isLogistics && } diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 4329337..77f52ce 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -3576,3 +3576,4 @@ [data-radix-popper-content-wrapper] { z-index: 1300 !important; } + diff --git a/src/components/frontend/dev/DevDashboardGate.tsx b/src/components/frontend/dev/DevDashboardGate.tsx new file mode 100644 index 0000000..ec33498 --- /dev/null +++ b/src/components/frontend/dev/DevDashboardGate.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +/** + * Dev-only guard for routes whose server render is pathologically slow under + * the Next.js 16 dev runtime (see payloadcms/payload#14487): the dashboard + * takes roughly 100s per render in dev while the browser gives up around 60s, + * so clicking any link to it looks like a hang. This component intercepts + * plain left-clicks on anchors pointing at those routes, shows a confirm + * dialog, and only navigates when the user insists. + * + * Gaps by design: direct URL entry, page refresh, modifier-clicks (Ctrl/Cmd + + * click), and programmatic router.push calls (e.g. the command palette) are + * not gated. Anchors inside a [data-dev-gate-bypass] ancestor are also + * exempt, so a specific link can opt out. + */ + +const GATED_PATHS = new Set(["/"]); + +function resolveTarget(href: string): string | null { + try { + const url = new URL(href, window.location.origin); + if (url.origin !== window.location.origin) return null; + if (!GATED_PATHS.has(url.pathname)) return null; + return url.pathname + url.search + url.hash; + } catch { + return null; + } +} + +export function DevDashboardGate() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [target, setTarget] = useState("/"); + const proceedRef = useRef<() => void>(() => undefined); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") return undefined; + + function onClick(event: MouseEvent) { + if (event.defaultPrevented) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + const anchor = (event.target as Element | null)?.closest?.("a"); + if (!anchor) return; + if (anchor.hasAttribute("download")) return; + const targetAttr = anchor.getAttribute("target"); + if (targetAttr && targetAttr !== "_self") return; + if (anchor.closest("[data-dev-gate-bypass]")) return; + const next = resolveTarget(anchor.getAttribute("href") ?? ""); + if (!next) return; + + event.preventDefault(); + event.stopPropagation(); + proceedRef.current = () => { + setOpen(false); + router.push(next); + }; + setTarget(next); + setOpen(true); + } + + document.addEventListener("click", onClick, { capture: true }); + return () => document.removeEventListener("click", onClick, { capture: true }); + }, [router]); + + if (process.env.NODE_ENV !== "development") return null; + + return ( + { + if (!next) setOpen(false); + }} + > + + + Open the dashboard in dev mode? + + In dev, {target} renders the dashboard + server-side in roughly 100 seconds (a known Next.js 16 dev-runtime regression) and the + browser usually gives up around 60s with an empty response. Production serves the same + page in a few seconds. Stay here, or open anyway and wait. + + + + Stay here + proceedRef.current()}>Open anyway + + + + ); +}