diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx
new file mode 100644
index 00000000..57a88e9b
--- /dev/null
+++ b/src/app/client/client-view.tsx
@@ -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 {children};
+ }
+ return ;
+}
+
+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 (
+
+
+
+
+
+
window.location.reload()} title="Reload">
+
+
+
+
+
+
+
+
{onlineCount}
+
+
+
+
+
+ );
+}
diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx
index 98439704..b3fa81cc 100644
--- a/src/app/client/page.tsx
+++ b/src/app/client/page.tsx
@@ -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 {children};
- }
- return ;
-}
+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(null);
- const [clientUrl, setClientUrl] = useState("/nitro-client/");
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(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 (
-
-
-
- );
- }
-
- function renderHeader() {
- return (
-
- );
- }
-
- if (errorType === "auth") {
- return (
-
- {renderHeader()}
-
-
-
-
- {t("errors.loginRedirect")}
-
-
-
-
-
- );
- }
-
- if (errorType === "generic" || !ticket) {
- return (
-
- {renderHeader()}
-
-
- {error ?? t("errors.generic")}
-
-
-
- );
- }
-
- const clientSrc = getClientSrc();
+ const onlineCount = await cached("online_count", 10_000, () =>
+ prisma.user.count({ where: { online: "1" } }),
+ ).catch(() => 0);
return (
-
- {/* Floating Toolbar */}
-
-
-
-
-
window.location.reload()} title="Reload">
-
-
-
-
-
-
-
-
{onlineCount}
-
-
-
- {/* Game Iframe */}
-
-
+
);
}
diff --git a/src/components/home/user-view.tsx b/src/components/home/user-view.tsx
index 2036e51b..469900eb 100644
--- a/src/components/home/user-view.tsx
+++ b/src/components/home/user-view.tsx
@@ -80,7 +80,7 @@ export default async function UserView({ userId, username, look }: UserViewProps
/>
-
+