Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { LogOut } from "lucide-react";
|
|
import { signOut } from "next-auth/react";
|
|
import {
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import Link from "@/components/link";
|
|
|
|
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 base = clientUrl.replace(/(\?|&)sso=[^&]*/, "");
|
|
const sep = base.includes("?") ? "&" : "?";
|
|
const clientSrc = `${base}${sep}sso=${encodeURIComponent(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={() => signOut({ callbackUrl: "/" })}
|
|
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"
|
|
loading="eager"
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|