Inline SSO ticket generation in server component, prefetch client page from home, remove client API roundtrip

This commit is contained in:
openhands committed 2026-07-09 18:41:04 +02:00
1 parent cfb36e8007
commit 7fe3220359
3 files changed
+133 -198

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
function ToolbarBtn({ onClick, title, children, href }: {
onClick?: () => void; title: string; children: ReactNode; href?: string;
}) {
const cls = "w-10 h-10 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, #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)}`;
return (
<div className="relative w-screen h-screen overflow-hidden" style={{ backgroundColor: "var(--color-background)" }}>
<div className="fixed top-4 left-4 z-50 flex items-center gap-2">
<ToolbarBtn href="/" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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-10 px-3 rounded-lg border-2 flex items-center justify-center gap-1.5 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, #1a1a2e)",
}}
title="Online users"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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-xs font-bold">{onlineCount}</span>
</div>
</div>
<iframe
id="nitro"
src={clientSrc}
className="absolute inset-0 w-full h-full border-0 m-0 p-0 overflow-hidden"
title={hotelName}
allow="autoplay; fullscreen; gamepad"
/>
</div>
);
}
+28 -197
View File
@@ -1,207 +1,38 @@
"use client";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { ClientView } from "./client-view";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ContentCard, SkeletonCard } from "@/components/public/ui";
export const dynamic = "force-dynamic";
function ToolbarBtn({ onClick, title, children, href }: {
onClick?: () => void; title: string; children: ReactNode; href?: string;
}) {
const cls = "w-10 h-10 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, #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 default async function ClientPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
export default function ClientPage() {
const t = useTranslations("pages.client");
const [ticket, setTicket] = useState<string | null>(null);
const [clientUrl, setClientUrl] = useState("/nitro-client/");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<"auth" | "generic" | null>(null);
const [hotelName, setHotelName] = useState("");
const [isFullscreen, setIsFullscreen] = useState(false);
const [onlineCount, setOnlineCount] = useState(0);
const fetchedRef = useRef(false);
const userId = Number(session.user.id);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
const [hotelName, clientUrl] = await Promise.all([
siteSettings.get("hotel_name", "Atom"),
siteSettings.get("nitro_client_url", ""),
]);
async function init() {
try {
const [ssoRes] = await Promise.all([
fetch("/api/client/sso"),
fetch("/api/online/count").then(r => r.ok ? r.json() : { count: 0 }).then(d => setOnlineCount(d.count)).catch(() => {}),
]);
if (ssoRes.status === 401) {
setErrorType("auth");
setError(t("errors.unauthorized"));
return;
}
if (!ssoRes.ok) throw new Error("SSO fetch failed");
const data = await ssoRes.json();
setTicket(data.ticket);
setHotelName(data.hotelName);
if (data.clientUrl) setClientUrl(data.clientUrl);
} catch {
setErrorType("generic");
setError(t("errors.generic"));
} finally {
setLoading(false);
}
}
init();
const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0";
const ticket = await issueSsoTicket(prisma, userId, hotelName ?? "Atom", ip);
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);
}, [t]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
setIsFullscreen(true);
} else {
document.exitFullscreen().catch(() => {});
setIsFullscreen(false);
}
}, [setIsFullscreen]);
useEffect(() => {
function onFullscreenChange() {
setIsFullscreen(!!document.fullscreenElement);
}
document.addEventListener("fullscreenchange", onFullscreenChange);
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
}, []);
const getClientSrc = useCallback(() => {
if (!ticket) return "";
const base = clientUrl.replace(/(\?|&)sso=[^&]*/, "");
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}sso=${encodeURIComponent(ticket)}`;
}, [clientUrl, ticket]);
if (loading) {
return (
<div className="flex min-h-screen flex-col items-center justify-center" style={{ backgroundColor: "var(--color-background)", padding: "2rem" }}>
<SkeletonCard lines={2} />
</div>
);
}
function renderHeader() {
return (
<header
className="flex h-14 items-center justify-between border-b px-6 shrink-0"
style={{ backgroundColor: "var(--color-navbar)", borderColor: "var(--color-primary, #eeb425)" }}
>
<span className="font-bold text-lg" style={{ color: "var(--color-navbar-text, var(--color-text))" }}>{hotelName || "Atom"} - Hotel</span>
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold" style={{ color: "var(--color-navbar-text, var(--color-text-muted))" }}>
{onlineCount} Online
</span>
</div>
</header>
);
}
if (errorType === "auth") {
return (
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
{renderHeader()}
<main className="flex-1 flex items-center justify-center">
<ContentCard icon="🔒" title={t("title")} subtitle={t("errors.unauthorized")}>
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Link href="/login" className="btn btn-primary">
{t("errors.loginRedirect")}
</Link>
</div>
</ContentCard>
</main>
</div>
);
}
if (errorType === "generic" || !ticket) {
return (
<div className="flex min-h-screen flex-col" style={{ backgroundColor: "var(--color-background)" }}>
{renderHeader()}
<main className="flex-1 flex items-center justify-center">
<p className="text-center" style={{ color: "var(--color-text-muted)" }}>
{error ?? t("errors.generic")}
</p>
</main>
</div>
);
}
const clientSrc = getClientSrc();
const onlineCount = await cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }),
).catch(() => 0);
return (
<div className="relative w-screen h-screen overflow-hidden" style={{ backgroundColor: "var(--color-background)" }}>
{/* Floating Toolbar */}
<div className="fixed top-4 left-4 z-50 flex items-center gap-2">
<ToolbarBtn href="/" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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-10 px-3 rounded-lg border-2 flex items-center justify-center gap-1.5 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, #1a1a2e)",
}}
title="Online users"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<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-xs font-bold">{onlineCount}</span>
</div>
</div>
{/* Game Iframe */}
<iframe
id="nitro"
src={clientSrc}
className="absolute inset-0 w-full h-full border-0 m-0 p-0 overflow-hidden"
title={hotelName}
allow="autoplay; fullscreen; gamepad"
/>
</div>
<ClientView
ticket={ticket}
clientUrl={clientUrl || "/nitro-client/"}
hotelName={hotelName ?? "Atom"}
initialOnline={onlineCount}
/>
);
}
+1 -1
View File
@@ -80,7 +80,7 @@ export default async function UserView({ userId, username, look }: UserViewProps
/>
</Link>
<div className="flex items-center mb-4">
<Link href="/client" target="_blank" rel="noopener noreferrer">
<Link href="/client" target="_blank" rel="noopener noreferrer" prefetch={true}>
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"