feat(client): polished toolbar, live online count via SSE
CI / runtime-diagnostics (push) Skipped
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m27s

- Redesign in-game client toolbar with refined glass styling and buttons
- Live online count + emulator status streamed over SSE multicast
- Who's-online tooltip throttled to reduce repeated requests
- Fix show button so it always returns the hidden toolbar
- Use theme CSS variables instead of hardcoded colors
- Add toolbar translations across all 25 locales
This commit is contained in:
openhands committed 2026-09-02 21:14:42 +02:00
1 parent e1ecb190bc
commit 67b67798b9
32 files changed
+799 -102

No files matched your search

+12 -2
View File
@@ -9,6 +9,8 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card";
@@ -235,7 +237,7 @@ export default async function Home() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
{th("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</div>
<h1
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
@@ -337,21 +339,25 @@ export default async function Home() {
value: users,
label: tp("statsCitizens"),
icon: "/assets/images/icons/friends.png",
live: false,
},
{
value: rooms,
label: tp("statsRooms"),
icon: "/assets/images/icons/catalog.png",
live: false,
},
{
value: online,
label: tp("statsOnline"),
icon: "/assets/images/icons/navigation/me.png",
live: true,
},
{
value: articles.length,
label: tp("statsArticles"),
icon: "/assets/images/icons/article.gif",
live: false,
},
].map((s) => (
<SurfaceCard
@@ -359,7 +365,11 @@ export default async function Home() {
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
<AnimatedCounter value={s.value} />
{s.live ? (
<LiveOnlineCounter initial={s.value} />
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<div
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
+98
View File
@@ -0,0 +1,98 @@
// Server-Sent Events stream of the live online user count for the in-game
// client toolbar. Pushes { count } every ~5s so the counter updates in
// real-time without the client polling. Closes cleanly when the client
// disconnects.
//
// The first event is sent immediately; subsequent events push the cached
// count (10s TTL in the REST endpoint) so repeated reads share one query.
import { count, eq } from "drizzle-orm";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { rcon } from "@/lib/services/rcon";
async function fetchOnlineCount(): Promise<number> {
try {
return await cached("online_count", 10_000, async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
} catch {
return 0;
}
}
// Emulator (RCON) reachability, cached and shared across all subscribers so an
// N-client toolbar doesn't fire N pings every interval. Falls back to graceful
// degradation on failure so the stream never errors out.
async function fetchEmulatorStatus(): Promise<boolean> {
try {
return await cached("emulator_rcon_status", 15_000, () =>
rcon.send("ping", null).catch(() => false),
);
} catch {
return false;
}
}
export async function GET(req: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let closed = false;
const send = async () => {
if (closed) return;
const [count, emulator] = await Promise.all([
fetchOnlineCount(),
fetchEmulatorStatus(),
]);
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ count, emulator })}\n\n`),
);
} catch {
closed = true;
}
};
// Initial event immediately, then on an interval.
await send();
const interval = setInterval(() => void send(), 5_000);
// SSE comment as a keep-alive ping between data events.
const ping = setInterval(() => {
if (!closed) {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
closed = true;
}
}
}, 25_000);
const stop = () => {
closed = true;
clearInterval(interval);
clearInterval(ping);
try {
controller.close();
} catch {
/* already closed */
}
};
req.signal.addEventListener("abort", stop);
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
},
});
}
+295 -94
View File
@@ -1,6 +1,7 @@
"use client";
import { LogOut } from "lucide-react";
import { useTranslations } from "next-intl";
import {
type ReactNode,
useCallback,
@@ -12,45 +13,78 @@ import {
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,
title,
label,
children,
href,
}: {
onClick?: () => void;
title: string;
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 =
"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";
"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 = {
borderColor: "color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 85%, white)",
background:
"linear-gradient(135deg, var(--color-primary, #eeb425) 0%, color-mix(in srgb, var(--color-primary, #eeb425) 80%, black) 100%)",
"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;
if (href) {
return (
<Link href={href} className={cls} style={style} title={title}>
{children}
</Link>
);
}
return (
const trigger = href ? (
<Link
href={href}
className={cls}
style={style}
aria-label={label}
onMouseEnter={() => setTip(true)}
onMouseLeave={() => setTip(false)}
onFocus={() => setTip(true)}
onBlur={() => setTip(false)}
>
{children}
</Link>
) : (
<button
onClick={onClick}
className={cls}
style={style}
title={title}
type="button"
aria-label={label}
onMouseEnter={() => setTip(true)}
onMouseLeave={() => setTip(false)}
onFocus={() => setTip(true)}
onBlur={() => setTip(false)}
>
{children}
</button>
);
return (
<span className="relative inline-flex">
{trigger}
{tip && (
<span
role="tooltip"
className="absolute left-1/2 -translate-x-1/2 -top-8 whitespace-nowrap rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none"
>
{label}
</span>
)}
</span>
);
}
export function ClientView({
@@ -64,17 +98,47 @@ export function ClientView({
hotelName: string;
initialOnline: number;
}) {
const [_isFullscreen, setIsFullscreen] = useState(false);
const tToolbar = useTranslations("toolbar");
const [isFullscreen, setIsFullscreen] = useState(false);
const [onlineCount, setOnlineCount] = useState(initialOnline);
const [emulatorOk, setEmulatorOk] = useState<boolean | null>(null);
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
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(() => {
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);
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<number>(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(() => {
@@ -163,16 +227,18 @@ export function ClientView({
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button, a")) return;
startDrag(e.clientX, e.clientY);
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("button, a")) return;
startDrag(e.touches[0].clientX, e.touches[0].clientY);
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
startDrag(e.touches[0].clientX, e.touches[0].clientY);
}
},
[startDrag],
);
@@ -225,22 +291,24 @@ export function ClientView({
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-foreground/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
className="fixed z-[9999] rounded-2xl shadow-2xl px-1.5 py-1.5 select-none backdrop-blur-2xl"
style={{
top: pos.top,
left: pos.left,
transition: snapTween
? "top 0.18s ease-out, left 0.18s ease-out"
: undefined,
background:
"linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04))",
border: "1px solid rgba(255,255,255,0.14)",
boxShadow:
"0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.12), inset 0 0 24px rgba(255,255,255,0.04)",
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div className="flex items-center gap-1 sm:gap-2">
<ToolbarBtn href="/" title="Home">
<div className="flex items-center gap-1 sm:gap-1.5">
<ToolbarBtn href="/" label={tToolbar("home")}>
<svg
width="14"
height="14"
@@ -251,60 +319,16 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Home"
aria-label={tToolbar("home")}
>
<title>Home</title>
<title>{tToolbar("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"
<ToolbarBtn
onClick={() => window.location.reload()}
label={tToolbar("reload")}
>
<svg
width="14"
@@ -316,27 +340,143 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Online users"
aria-label={tToolbar("reload")}
>
<title>Online users</title>
<title>{tToolbar("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}
label={
isFullscreen
? tToolbar("fullscreenExit")
: tToolbar("fullscreen")
}
>
{isFullscreen ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("fullscreenExit")}
>
<title>{tToolbar("fullscreenExit")}</title>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("fullscreen")}
>
<title>{tToolbar("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-8 sm:h-9 rounded-xl flex items-center justify-center px-2"
style={{
border:
"1px solid color-mix(in srgb, var(--color-primary, #eeb425) 50%, transparent)",
background:
"color-mix(in srgb, var(--color-primary, #eeb425) 14%, rgba(255,255,255,0.05))",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.20)",
}}
role="status"
title={
emulatorOk === false
? tToolbar("emulatorOffline")
: tToolbar("emulatorOnline")
}
>
<span className="relative flex h-2.5 w-2.5">
{emulatorOk === false ? (
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-destructive ring-2 ring-destructive/30 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
) : (
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-success ring-2 ring-success/30 shadow-[0_0_8px_rgba(52,211,153,0.8)]" />
)}
</span>
</div>
<div
className="relative h-8 sm:h-9 px-2 sm:px-2.5 rounded-xl flex items-center justify-center gap-1.5 select-none cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 active:translate-y-0"
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)",
}}
role="status"
aria-label={tToolbar("onlineUsers")}
onMouseEnter={() => {
setShowOnlineUsers(true);
refreshOnlineUsers();
}}
onMouseLeave={() => setShowOnlineUsers(false)}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-background/80 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-background shadow-[0_0_8px_rgba(255,255,255,0.9)]" />
</span>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("onlineUsers")}
>
<title>{tToolbar("onlineUsers")}</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>
{showOnlineUsers && onlineUsers.length > 0 && (
<span className="absolute left-1/2 -translate-x-1/2 -top-10 max-w-[200px] rounded-md bg-foreground backdrop-blur px-2 py-1 text-[10px] font-bold text-background shadow-lg pointer-events-none whitespace-normal text-center">
{onlineUsers.slice(0, 8).join(", ")}
{onlineUsers.length > 8
? ` +${onlineUsers.length - 8} more`
: ""}
</span>
)}
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
<div
className="h-7 sm:h-8 px-1 rounded-lg border-2 flex items-center justify-center shadow-lg opacity-50"
className="toolbar-grip h-8 sm:h-9 px-1.5 rounded-xl flex items-center justify-center cursor-grab active:cursor-grabbing touch-none opacity-60 hover:opacity-100 transition-opacity duration-200"
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))",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.85)",
}}
title="Drag to move"
title={tToolbar("dragToMove")}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<svg
width="12"
@@ -348,9 +488,9 @@ export function ClientView({
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Drag"
aria-label={tToolbar("dragToMove")}
>
<title>Drag</title>
<title>{tToolbar("dragToMove")}</title>
<circle cx="9" cy="5" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="9" cy="12" r="1" />
@@ -361,14 +501,75 @@ export function ClientView({
</div>
<ToolbarBtn
onClick={() => void signOutAndRevokeTicket()}
title="Logout"
label={tToolbar("logout")}
>
<LogOut size={14} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => setBarHidden(true)}
label={tToolbar("hide")}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("hide")}
>
<title>{tToolbar("hide")}</title>
<path d="M9 18l6-6-6-6" />
</svg>
</ToolbarBtn>
</div>
</div>
) : null}
{barHidden && pos ? (
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
setBarHidden(false);
}}
className="fixed z-[9999] h-8 rounded-xl flex items-center justify-center px-2 cursor-pointer transition-all duration-200 opacity-80 hover:opacity-100 hover:-translate-y-0.5 active:translate-y-0 active:scale-90"
style={{
top: pos.top,
left: pos.left,
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)",
}}
aria-label={tToolbar("show")}
title={tToolbar("show")}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label={tToolbar("show")}
>
<title>{tToolbar("show")}</title>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
) : null}
<div
className="relative w-screen h-screen overflow-hidden"
style={{ backgroundColor: "var(--color-background)" }}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useEventSource } from "@/lib/use-event-source";
/**
* Live online user counter. Subscribes to the SSE online-count stream so the
* displayed count updates in real-time (multicast) instead of only reflecting
* the server-rendered value at page load.
*/
export function LiveOnlineCount({
initial,
hotelName,
}: {
initial: number;
hotelName: string;
}) {
const t = useTranslations("header");
const [count, setCount] = useState(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <>{t("online", { count, hotel: hotelName })}</>;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { useEventSource } from "@/lib/use-event-source";
/**
* Live animated online-count number. Subscribes to the SSE online-count stream
* so the stat updates in real-time (multicast) instead of only the initial SSR
* value, while keeping the entrance counter animation.
*/
export function LiveOnlineCounter({
initial,
suffix = "",
}: {
initial: number;
suffix?: string;
}) {
const [count, setCount] = useState(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <AnimatedCounter value={count} suffix={suffix} />;
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { count, eq } from "drizzle-orm";
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { siteSettings } from "@/lib/services/site-settings";
export async function SiteHeader() {
const t = await getTranslations("header");
const [hotelName, header, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_header", "/assets/images/background.png"),
@@ -140,7 +139,7 @@ export async function SiteHeader() {
className="text-xs sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</div>
</div>
+2 -3
View File
@@ -1,10 +1,10 @@
import { count, eq, inArray } from "drizzle-orm";
import Image from "next/image";
import type { Session } from "next-auth";
import { getTranslations } from "next-intl/server";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import { HeaderUserIdentity } from "@/components/header-user-identity";
import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { RoomQuickEntry } from "@/components/room-quick-entry";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
@@ -100,7 +100,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
const t = await getTranslations("header");
const hotelName = await resolveHotelName();
let online: number;
@@ -173,7 +172,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
className="text-[10px] sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName })}
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</div>
+13
View File
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
},
"toolbar": {
"home": "الرئيسية",
"reload": "إعادة تحميل",
"fullscreen": "ملء الشاشة",
"logout": "تسجيل الخروج",
"onlineUsers": "المستخدمون المتواجدون",
"emulatorOnline": "المحاكي متصل",
"emulatorOffline": "المحاكي غير متصل",
"dragToMove": "اسحب للتحريك",
"hide": "إخفاء الشريط",
"fullscreenExit": "الخروج من ملء الشاشة",
"show": "إظهار الشريط"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Начало",
"reload": "Презареждане",
"fullscreen": "Цял екран",
"logout": "Изход",
"onlineUsers": "Потребители онлайн",
"emulatorOnline": "Емулаторът е онлайн",
"emulatorOffline": "Емулаторът е офлайн",
"dragToMove": "Влачете за преместване",
"hide": "Скрий лентата",
"fullscreenExit": "Изход от цял екран",
"show": "Покажи лентата"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Domů",
"reload": "Obnovit",
"fullscreen": "Celá obrazovka",
"logout": "Odhlásit",
"onlineUsers": "Uživatelé online",
"emulatorOnline": "Emulátor online",
"emulatorOffline": "Emulátor offline",
"dragToMove": "Přetažením přesunete",
"hide": "Skrýt lištu",
"fullscreenExit": "Ukončit celou obrazovku",
"show": "Zobrazit lištu"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Hjem",
"reload": "Genindlæs",
"fullscreen": "Fuldskærm",
"logout": "Log ud",
"onlineUsers": "Brugere online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Træk for at flytte",
"hide": "Skjul værktøjslinje",
"fullscreenExit": "Afslut fuldskærm",
"show": "Vis værktøjslinje"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Startseite",
"reload": "Neu laden",
"fullscreen": "Vollbild",
"logout": "Abmelden",
"onlineUsers": "Nutzer online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Ziehen zum Verschieben",
"hide": "Leiste ausblenden",
"fullscreenExit": "Vollbild beenden",
"show": "Leiste anzeigen"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Αρχική",
"reload": "Επαναφόρτωση",
"fullscreen": "Πλήρης οθόνη",
"logout": "Αποσύνδεση",
"onlineUsers": "Χρήστες συνδεδεμένοι",
"emulatorOnline": "Ο εξομοιωτής είναι online",
"emulatorOffline": "Ο εξομοιωτής είναι offline",
"dragToMove": "Σύρετε για μετακίνηση",
"hide": "Απόκρυψη γραμμής",
"fullscreenExit": "Έξοδος από πλήρη οθόνη",
"show": "Εμφάνιση γραμμής"
}
}
+13
View File
@@ -3321,5 +3321,18 @@
}
}
}
},
"toolbar": {
"home": "Home",
"reload": "Reload",
"fullscreen": "Fullscreen",
"logout": "Logout",
"onlineUsers": "Online users",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Drag to move",
"hide": "Hide toolbar",
"fullscreenExit": "Exit fullscreen",
"show": "Show toolbar"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Inicio",
"reload": "Recargar",
"fullscreen": "Pantalla completa",
"logout": "Cerrar sesión",
"onlineUsers": "Usuarios en línea",
"emulatorOnline": "Emulador en línea",
"emulatorOffline": "Emulador fuera de línea",
"dragToMove": "Arrastra para mover",
"hide": "Ocultar barra",
"fullscreenExit": "Salir de pantalla completa",
"show": "Mostrar barra"
}
}
+13
View File
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
},
"toolbar": {
"home": "Koti",
"reload": "Lataa uudelleen",
"fullscreen": "Koko näyttö",
"logout": "Kirjaudu ulos",
"onlineUsers": "Käyttäjät verkossa",
"emulatorOnline": "Emulaattori verkossa",
"emulatorOffline": "Emulaattori ei verkossa",
"dragToMove": "Vedä liikuttaaksesi",
"hide": "Piilota työkalupalkki",
"fullscreenExit": "Lopeta koko näyttö",
"show": "Näytä työkalupalkki"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Accueil",
"reload": "Recharger",
"fullscreen": "Plein écran",
"logout": "Déconnexion",
"onlineUsers": "Utilisateurs en ligne",
"emulatorOnline": "Émulateur en ligne",
"emulatorOffline": "Émulateur hors ligne",
"dragToMove": "Glissez pour déplacer",
"hide": "Masquer la barre",
"fullscreenExit": "Quitter le plein écran",
"show": "Afficher la barre"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Početna",
"reload": "Ponovno učitaj",
"fullscreen": "Cijeli zaslon",
"logout": "Odjava",
"onlineUsers": "Korisnici online",
"emulatorOnline": "Emulator je online",
"emulatorOffline": "Emulator je offline",
"dragToMove": "Povucite za pomicanje",
"hide": "Sakrij traku",
"fullscreenExit": "Izađi iz cijelog zaslona",
"show": "Prikaži traku"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Kezdőlap",
"reload": "Újratöltés",
"fullscreen": "Teljes képernyő",
"logout": "Kijelentkezés",
"onlineUsers": "Online felhasználók",
"emulatorOnline": "Emulátor online",
"emulatorOffline": "Emulátor offline",
"dragToMove": "Húzás a mozgatáshoz",
"hide": "Sáv elrejtése",
"fullscreenExit": "Kilépés a teljes képernyőből",
"show": "Sáv megjelenítése"
}
}
+13
View File
@@ -3320,5 +3320,18 @@
}
}
}
},
"toolbar": {
"home": "Home",
"reload": "Ricarica",
"fullscreen": "Schermo intero",
"logout": "Esci",
"onlineUsers": "Utenti online",
"emulatorOnline": "Emulatore online",
"emulatorOffline": "Emulatore offline",
"dragToMove": "Trascina per spostare",
"hide": "Nascondi barra",
"fullscreenExit": "Esci dalla schermo intero",
"show": "Mostra barra"
}
}
+13
View File
@@ -9,5 +9,18 @@
"studio": "Studio"
}
}
},
"toolbar": {
"home": "ホーム",
"reload": "再読み込み",
"fullscreen": "全画面",
"logout": "ログアウト",
"onlineUsers": "オンラインのユーザー",
"emulatorOnline": "エミュレータオンライン",
"emulatorOffline": "エミュレータオフライン",
"dragToMove": "ドラッグして移動",
"hide": "ツールバーを隠す",
"fullscreenExit": "全画面を終了",
"show": "ツールバーを表示"
}
}
+13
View File
@@ -3265,5 +3265,18 @@
}
}
}
},
"toolbar": {
"home": "Home",
"reload": "Vernieuwen",
"fullscreen": "Volledig scherm",
"logout": "Uitloggen",
"onlineUsers": "Gebruikers online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Sleep om te verplaatsen",
"hide": "Werkbalk verbergen",
"fullscreenExit": "Volledig scherm afsluiten",
"show": "Werkbalk tonen"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Hjem",
"reload": "Last inn på nytt",
"fullscreen": "Fullskjerm",
"logout": "Logg ut",
"onlineUsers": "Brukere på nett",
"emulatorOnline": "Emulator på nett",
"emulatorOffline": "Emulator frakoblet",
"dragToMove": "Dra for å flytte",
"hide": "Skjul verktøylinje",
"fullscreenExit": "Avslutt fullskjerm",
"show": "Vis verktøylinje"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Start",
"reload": "Przeładuj",
"fullscreen": "Pełny ekran",
"logout": "Wyloguj",
"onlineUsers": "Użytkownicy online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Przeciągnij, aby przenieść",
"hide": "Ukryj pasek",
"fullscreenExit": "Zamknij pełny ekran",
"show": "Pokaż pasek"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Início",
"reload": "Recarregar",
"fullscreen": "Tela cheia",
"logout": "Sair",
"onlineUsers": "Usuários online",
"emulatorOnline": "Emulador online",
"emulatorOffline": "Emulador offline",
"dragToMove": "Arraste para mover",
"hide": "Ocultar barra",
"fullscreenExit": "Sair da tela cheia",
"show": "Mostrar barra"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Acasă",
"reload": "Reîncarcă",
"fullscreen": "Ecran complet",
"logout": "Deconectare",
"onlineUsers": "Utilizatori online",
"emulatorOnline": "Emulatorul este online",
"emulatorOffline": "Emulatorul este offline",
"dragToMove": "Trageți pentru a muta",
"hide": "Ascunde bara",
"fullscreenExit": "Ieșiți din ecran complet",
"show": "Afișați bara"
}
}
+13
View File
@@ -3261,5 +3261,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Главная",
"reload": "Перезагрузить",
"fullscreen": "Полный экран",
"logout": "Выйти",
"onlineUsers": "Пользователи онлайн",
"emulatorOnline": "Эмулятор онлайн",
"emulatorOffline": "Эмулятор офлайн",
"dragToMove": "Перетащите, чтобы переместить",
"hide": "Скрыть панель",
"fullscreenExit": "Выйти из полноэкранного режима",
"show": "Показать панель"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Domov",
"reload": "Obnoviť",
"fullscreen": "Celá obrazovka",
"logout": "Odhlásiť",
"onlineUsers": "Používatelia online",
"emulatorOnline": "Emulátor online",
"emulatorOffline": "Emulátor offline",
"dragToMove": "Potiahnite na presun",
"hide": "Skryť panel",
"fullscreenExit": "Ukončiť celú obrazovku",
"show": "Zobraziť panel"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Почетна",
"reload": "Освежи",
"fullscreen": "Цео екран",
"logout": "Одјава",
"onlineUsers": "Корисници на мрежи",
"emulatorOnline": "Емулатор онлајн",
"emulatorOffline": "Емулатор офлајн",
"dragToMove": "Превуците да померите",
"hide": "Сакриј траку",
"fullscreenExit": "Изађи из целог екрана",
"show": "Прикажи траку"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Hem",
"reload": "Ladda om",
"fullscreen": "Helskärm",
"logout": "Logga ut",
"onlineUsers": "Användare online",
"emulatorOnline": "Emulator online",
"emulatorOffline": "Emulator offline",
"dragToMove": "Dra för att flytta",
"hide": "Dölj verktygsfält",
"fullscreenExit": "Avsluta helskärm",
"show": "Visa verktygsfält"
}
}
+13
View File
@@ -3263,5 +3263,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Ana sayfa",
"reload": "Yenile",
"fullscreen": "Tam ekran",
"logout": "Çıkış",
"onlineUsers": "Çevrimiçi kullanıcılar",
"emulatorOnline": "Emülatör çevrimiçi",
"emulatorOffline": "Emülatör çevrimdışı",
"dragToMove": "Taşımak için sürükleyin",
"hide": "Araç çubuğunu gizle",
"fullscreenExit": "Tam ekrandan çık",
"show": "Araç çubuğunu göster"
}
}
+13
View File
@@ -3262,5 +3262,18 @@
"button": "Verify email",
"fallback": "If the button doesn't work, paste this link into your browser:"
}
},
"toolbar": {
"home": "Головна",
"reload": "Перезавантажити",
"fullscreen": "Повний екран",
"logout": "Вийти",
"onlineUsers": "Користувачі онлайн",
"emulatorOnline": "Емулятор онлайн",
"emulatorOffline": "Емулятор офлайн",
"dragToMove": "Перетягніть, щоб перемістити",
"hide": "Сховати панель",
"fullscreenExit": "Вийти з повноекранного режиму",
"show": "Показати панель"
}
}