Files
EpicNext-Cms/src/app/client/client-view.tsx
T
openhands 7a41775c7e fix: disable route prefetching app-wide to avoid spurious requests
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.
2026-08-29 19:27:13 +02:00

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>
</>
);
}