"use client"; import { LogOut } from "lucide-react"; import { useTranslations } from "next-intl"; 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"; import { useEventSource } from "@/lib/use-event-source"; function ToolbarBtn({ onClick, label, children, href, }: { onClick?: () => void; label: string; children: ReactNode; href?: string; }) { // Custom floating tooltip: appears on hover/focus regardless of where the // element is on screen. Kept in one component for a consistent look across // the bar (replaces the browser's slow, untitled `title` bubbles). const [tip, setTip] = useState(false); const cls = "group relative w-8 h-8 sm:w-9 sm:h-9 rounded-xl cursor-pointer flex items-center justify-center transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"; const style = { border: "1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)", background: "linear-gradient(180deg, color-mix(in srgb, var(--color-primary, #eeb425) 92%, white), var(--color-primary, #eeb425) 55%, color-mix(in srgb, var(--color-primary, #eeb425) 60%, black))", color: "var(--button-text-color-readable, var(--button-text-color, #1a1a2e))", boxShadow: "0 2px 8px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -2px 4px rgba(0,0,0,0.12)", } as const; const trigger = href ? ( setTip(true)} onMouseLeave={() => setTip(false)} onFocus={() => setTip(true)} onBlur={() => setTip(false)} > {children} ) : ( ); return ( {trigger} {tip && ( {label} )} ); } export function ClientView({ ticket, clientUrl, hotelName, initialOnline, }: { ticket: string; clientUrl: string; hotelName: string; initialOnline: number; }) { const tToolbar = useTranslations("toolbar"); const [isFullscreen, setIsFullscreen] = useState(false); const [onlineCount, setOnlineCount] = useState(initialOnline); const [emulatorOk, setEmulatorOk] = useState(null); const [onlineUsers, setOnlineUsers] = useState([]); const [showOnlineUsers, setShowOnlineUsers] = useState(false); const [barHidden, setBarHidden] = useState(false); // Real-time online count + emulator status via SSE multicast (replaces the // legacy 15s polling). const { data: onlineStream } = useEventSource<{ count: number; emulator: boolean; }>("/api/online/count/stream", { enabled: true }); useEffect(() => { if (!onlineStream) return; if (typeof onlineStream.count === "number") setOnlineCount(onlineStream.count); if (typeof onlineStream.emulator === "boolean") setEmulatorOk(onlineStream.emulator); }, [onlineStream]); // "Who's online" list, fetched sparingly (server-side cached 10s). Only a // fresh fetch is issued when hovered and the last fetch is stale — hovering // repeatedly must not spam the endpoint. const onlineUsersRef = useRef(0); const refreshOnlineUsers = useCallback(() => { const now = Date.now(); if (now - onlineUsersRef.current < 30_000) return; onlineUsersRef.current = now; fetch("/api/online", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .then((data) => { const usernames = Array.isArray(data?.users) ? data.users .map((u: { username?: string }) => u.username) .filter((n: unknown): n is string => typeof n === "string") : []; setOnlineUsers(usernames); }) .catch(() => setOnlineUsers([])); }, []); 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(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(".toolbar-grip")) { startDrag(e.clientX, e.clientY); } }, [startDrag], ); const handleTouchStart = useCallback( (e: React.TouchEvent) => { if ((e.target as HTMLElement).closest(".toolbar-grip")) { 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 ? (
{tToolbar("home")} window.location.reload()} label={tToolbar("reload")} > {tToolbar("reload")} {isFullscreen ? ( {tToolbar("fullscreenExit")} ) : ( {tToolbar("fullscreen")} )}
{emulatorOk === false ? ( ) : ( )}
{ setShowOnlineUsers(true); refreshOnlineUsers(); }} onMouseLeave={() => setShowOnlineUsers(false)} > {tToolbar("onlineUsers")} {onlineCount} {showOnlineUsers && onlineUsers.length > 0 && ( {onlineUsers.slice(0, 8).join(", ")} {onlineUsers.length > 8 ? ` +${onlineUsers.length - 8} more` : ""} )}
{/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
{tToolbar("dragToMove")}
void signOutAndRevokeTicket()} label={tToolbar("logout")} > setBarHidden(true)} label={tToolbar("hide")} > {tToolbar("hide")}
) : null} {barHidden && pos ? ( ) : null}