Files
Epicnabbo-Catalogus-Updated…/src/app/client/client-view.tsx
T
openhands b59d6c21af feat: prioritize game iframe, gated register terms and polished register page
Fetch the Nitro client iframe with high priority so the browser starts the
game document before competing resources, and replace the bare /client
spinner with a branded boot screen. Preconnect and eager loading were already
in place; typing support for the iframe fetchPriority prop is added in a
React type augmentation.

Rework the register terms block into a single clickable accept control with
a custom check state, error shake and inline hint, and dim the submit button
until the terms are accepted. The register page gets labeled sections
(account details / credentials), a corrected banner overlay, translated
show/hide toggles and a captcha slot that reserves height to avoid layout
shifts. English is the source of truth; other locales fall back to it.
2026-08-29 21:14:02 +02:00

392 lines
11 KiB
TypeScript

"use client";
import { LogOut } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import Link from "@/components/link";
import { buildClientLoginUrl } from "@/lib/client-url";
function ToolbarBtn({
onClick,
title,
children,
href,
}: {
onClick?: () => void;
title: string;
children: ReactNode;
href?: string;
}) {
const cls =
"w-7 h-7 sm:w-8 sm:h-8 rounded-lg border-2 cursor-pointer flex items-center justify-center shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl active:translate-y-0";
const style = {
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
} as const;
if (href) {
return (
<Link href={href} className={cls} style={style} title={title}>
{children}
</Link>
);
}
return (
<button
onClick={onClick}
className={cls}
style={style}
title={title}
type="button"
>
{children}
</button>
);
}
export function ClientView({
ticket,
clientUrl,
hotelName,
initialOnline,
}: {
ticket: string;
clientUrl: string;
hotelName: string;
initialOnline: number;
}) {
const [_isFullscreen, setIsFullscreen] = useState(false);
const [onlineCount, setOnlineCount] = useState(initialOnline);
useEffect(() => {
const interval = setInterval(async () => {
try {
const res = await fetch("/api/online/count");
if (res.ok) setOnlineCount((await res.json()).count ?? 0);
} catch {}
}, 15000);
return () => clearInterval(interval);
}, []);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
setIsFullscreen(true);
} else {
document.exitFullscreen().catch(() => {});
setIsFullscreen(false);
}
}, []);
useEffect(() => {
function onChange() {
setIsFullscreen(!!document.fullscreenElement);
}
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const clientSrc = buildClientLoginUrl(clientUrl, ticket);
const toolbarRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ startX: 0, startY: 0, startTop: 0, startLeft: 0 });
const posRef = useRef({ top: 8, left: 8 });
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [dragging, setDragging] = useState(false);
useLayoutEffect(() => {
const params = new URLSearchParams(window.location.search);
const ux = params.get("toolbarX"),
uy = params.get("toolbarY");
if (ux && uy) {
setPos({ top: Number(uy), left: Number(ux) });
return;
}
try {
const sx = localStorage.getItem("client_toolbar_x");
const sy = localStorage.getItem("client_toolbar_y");
if (sx && sy) {
setPos({ top: Number(sy), left: Number(sx) });
return;
}
} catch {}
const w = toolbarRef.current?.offsetWidth ?? 220;
setPos({ top: 8, left: Math.round((window.innerWidth - w) / 2) });
}, []);
useEffect(() => {
if (pos) posRef.current = pos;
}, [pos]);
const [snapTween, setSnapTween] = useState(false);
const SNAP = 40;
function snapPos(top: number, left: number) {
const el = toolbarRef.current;
if (!el) return { top, left };
const w = el.offsetWidth,
h = el.offsetHeight;
const vw = window.innerWidth,
vh = window.innerHeight;
if (top < SNAP) top = 0;
else if (top + h > vh - SNAP) top = vh - h;
if (left < SNAP) left = 0;
else if (left + w > vw - SNAP) left = vw - w;
return { top, left };
}
const startDrag = useCallback(
(clientX: number, clientY: number) => {
if (!pos) return;
setSnapTween(false);
dragRef.current = {
startX: clientX,
startY: clientY,
startTop: pos.top,
startLeft: pos.left,
};
setDragging(true);
},
[pos],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button, a")) return;
startDrag(e.clientX, e.clientY);
},
[startDrag],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if ((e.target as HTMLElement).closest("button, a")) return;
startDrag(e.touches[0].clientX, e.touches[0].clientY);
},
[startDrag],
);
useEffect(() => {
if (!dragging) return;
const maxX = window.innerWidth - (toolbarRef.current?.offsetWidth ?? 220);
const maxY = window.innerHeight - (toolbarRef.current?.offsetHeight ?? 40);
function onMove(e: MouseEvent) {
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
setPos({
top: Math.max(0, Math.min(dragRef.current.startTop + dy, maxY)),
left: Math.max(0, Math.min(dragRef.current.startLeft + dx, maxX)),
});
}
function onTouchMove(e: TouchEvent) {
const dx = e.touches[0].clientX - dragRef.current.startX;
const dy = e.touches[0].clientY - dragRef.current.startY;
setPos({
top: Math.max(0, Math.min(dragRef.current.startTop + dy, maxY)),
left: Math.max(0, Math.min(dragRef.current.startLeft + dx, maxX)),
});
}
function onEnd() {
setDragging(false);
const snapped = snapPos(posRef.current.top, posRef.current.left);
setPos(snapped);
setSnapTween(true);
setTimeout(() => setSnapTween(false), 180);
try {
localStorage.setItem("client_toolbar_x", String(snapped.left));
localStorage.setItem("client_toolbar_y", String(snapped.top));
} catch {}
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onEnd);
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onEnd);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onEnd);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onEnd);
};
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
}, [dragging, snapPos]);
return (
<>
{pos ? (
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
<div
ref={toolbarRef}
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
style={{
top: pos.top,
left: pos.left,
transition: snapTween
? "top 0.18s ease-out, left 0.18s ease-out"
: undefined,
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div className="flex items-center gap-1 sm:gap-2">
<ToolbarBtn href="/" title="Home">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Home"
>
<title>Home</title>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</ToolbarBtn>
<ToolbarBtn onClick={() => window.location.reload()} title="Reload">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Reload"
>
<title>Reload</title>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</ToolbarBtn>
<ToolbarBtn onClick={toggleFullscreen} title="Fullscreen">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Fullscreen"
>
<title>Fullscreen</title>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</ToolbarBtn>
<div
className="h-7 sm:h-8 px-1.5 sm:px-2 rounded-lg border-2 flex items-center justify-center gap-1 shadow-lg select-none"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
}}
title="Online users"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Online users"
>
<title>Online users</title>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="text-[10px] sm:text-xs font-bold">
{onlineCount}
</span>
</div>
<div
className="h-7 sm:h-8 px-1 rounded-lg border-2 flex items-center justify-center shadow-lg opacity-50"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
color:
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
}}
title="Drag to move"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Drag"
>
<title>Drag</title>
<circle cx="9" cy="5" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="9" cy="12" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>
</div>
<ToolbarBtn
onClick={() => void signOutAndRevokeTicket()}
title="Logout"
>
<LogOut size={14} />
</ToolbarBtn>
</div>
</div>
) : null}
<div
className="relative w-screen h-screen overflow-hidden"
style={{ backgroundColor: "var(--color-background)" }}
>
<iframe
id="nitro"
src={clientSrc}
className={`absolute inset-0 w-full h-full border-0 m-0 p-0 overflow-hidden ${
dragging ? "pointer-events-none" : "pointer-events-auto"
}`}
title={hotelName}
allow="autoplay; gamepad; fullscreen"
referrerPolicy="no-referrer"
loading="eager"
fetchPriority={"high" as const}
/>
</div>
</>
);
}