feat(client): polished toolbar, live online count via SSE
- 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:
1 parent
e1ecb190bc
commit
67b67798b9
32 files changed
+799
-102
No files matched your search
+12
-2
@@ -9,6 +9,8 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
|
|||||||
import { Clock } from "@/components/clock";
|
import { Clock } from "@/components/clock";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import Link from "@/components/link";
|
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 { Reveal } from "@/components/motion-reveal";
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { SurfaceCard } from "@/components/surface-card";
|
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="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 className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||||
</span>
|
</span>
|
||||||
{th("online", { count: online, hotel: hotelName })}
|
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
|
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,
|
value: users,
|
||||||
label: tp("statsCitizens"),
|
label: tp("statsCitizens"),
|
||||||
icon: "/assets/images/icons/friends.png",
|
icon: "/assets/images/icons/friends.png",
|
||||||
|
live: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: rooms,
|
value: rooms,
|
||||||
label: tp("statsRooms"),
|
label: tp("statsRooms"),
|
||||||
icon: "/assets/images/icons/catalog.png",
|
icon: "/assets/images/icons/catalog.png",
|
||||||
|
live: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: online,
|
value: online,
|
||||||
label: tp("statsOnline"),
|
label: tp("statsOnline"),
|
||||||
icon: "/assets/images/icons/navigation/me.png",
|
icon: "/assets/images/icons/navigation/me.png",
|
||||||
|
live: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: articles.length,
|
value: articles.length,
|
||||||
label: tp("statsArticles"),
|
label: tp("statsArticles"),
|
||||||
icon: "/assets/images/icons/article.gif",
|
icon: "/assets/images/icons/article.gif",
|
||||||
|
live: false,
|
||||||
},
|
},
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<SurfaceCard
|
<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"
|
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">
|
<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>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
|
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -12,45 +13,78 @@ import {
|
|||||||
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { buildClientLoginUrl } from "@/lib/client-url";
|
import { buildClientLoginUrl } from "@/lib/client-url";
|
||||||
|
import { useEventSource } from "@/lib/use-event-source";
|
||||||
|
|
||||||
function ToolbarBtn({
|
function ToolbarBtn({
|
||||||
onClick,
|
onClick,
|
||||||
title,
|
label,
|
||||||
children,
|
children,
|
||||||
href,
|
href,
|
||||||
}: {
|
}: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
title: string;
|
label: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
href?: string;
|
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 =
|
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 = {
|
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:
|
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:
|
color:
|
||||||
"var(--button-text-color-readable, var(--button-text-color, #1a1a2e))",
|
"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;
|
} as const;
|
||||||
if (href) {
|
|
||||||
return (
|
const trigger = href ? (
|
||||||
<Link href={href} className={cls} style={style} title={title}>
|
<Link
|
||||||
{children}
|
href={href}
|
||||||
</Link>
|
className={cls}
|
||||||
);
|
style={style}
|
||||||
}
|
aria-label={label}
|
||||||
return (
|
onMouseEnter={() => setTip(true)}
|
||||||
|
onMouseLeave={() => setTip(false)}
|
||||||
|
onFocus={() => setTip(true)}
|
||||||
|
onBlur={() => setTip(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cls}
|
className={cls}
|
||||||
style={style}
|
style={style}
|
||||||
title={title}
|
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
onMouseEnter={() => setTip(true)}
|
||||||
|
onMouseLeave={() => setTip(false)}
|
||||||
|
onFocus={() => setTip(true)}
|
||||||
|
onBlur={() => setTip(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</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({
|
export function ClientView({
|
||||||
@@ -64,17 +98,47 @@ export function ClientView({
|
|||||||
hotelName: string;
|
hotelName: string;
|
||||||
initialOnline: number;
|
initialOnline: number;
|
||||||
}) {
|
}) {
|
||||||
const [_isFullscreen, setIsFullscreen] = useState(false);
|
const tToolbar = useTranslations("toolbar");
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [onlineCount, setOnlineCount] = useState(initialOnline);
|
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(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(async () => {
|
if (!onlineStream) return;
|
||||||
try {
|
if (typeof onlineStream.count === "number")
|
||||||
const res = await fetch("/api/online/count");
|
setOnlineCount(onlineStream.count);
|
||||||
if (res.ok) setOnlineCount((await res.json()).count ?? 0);
|
if (typeof onlineStream.emulator === "boolean")
|
||||||
} catch {}
|
setEmulatorOk(onlineStream.emulator);
|
||||||
}, 15000);
|
}, [onlineStream]);
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
// "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(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
@@ -163,16 +227,18 @@ export function ClientView({
|
|||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest("button, a")) return;
|
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
|
||||||
startDrag(e.clientX, e.clientY);
|
startDrag(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[startDrag],
|
[startDrag],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
(e: React.TouchEvent) => {
|
(e: React.TouchEvent) => {
|
||||||
if ((e.target as HTMLElement).closest("button, a")) return;
|
if ((e.target as HTMLElement).closest(".toolbar-grip")) {
|
||||||
startDrag(e.touches[0].clientX, e.touches[0].clientY);
|
startDrag(e.touches[0].clientX, e.touches[0].clientY);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[startDrag],
|
[startDrag],
|
||||||
);
|
);
|
||||||
@@ -225,22 +291,24 @@ export function ClientView({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pos ? (
|
{pos ? (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
|
|
||||||
<div
|
<div
|
||||||
ref={toolbarRef}
|
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={{
|
style={{
|
||||||
top: pos.top,
|
top: pos.top,
|
||||||
left: pos.left,
|
left: pos.left,
|
||||||
transition: snapTween
|
transition: snapTween
|
||||||
? "top 0.18s ease-out, left 0.18s ease-out"
|
? "top 0.18s ease-out, left 0.18s ease-out"
|
||||||
: undefined,
|
: 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">
|
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||||
<ToolbarBtn href="/" title="Home">
|
<ToolbarBtn href="/" label={tToolbar("home")}>
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@@ -251,60 +319,16 @@ export function ClientView({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
role="img"
|
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" />
|
<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" />
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
</svg>
|
</svg>
|
||||||
</ToolbarBtn>
|
</ToolbarBtn>
|
||||||
<ToolbarBtn onClick={() => window.location.reload()} title="Reload">
|
<ToolbarBtn
|
||||||
<svg
|
onClick={() => window.location.reload()}
|
||||||
width="14"
|
label={tToolbar("reload")}
|
||||||
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
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
@@ -316,27 +340,143 @@ export function ClientView({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
role="img"
|
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" />
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[10px] sm:text-xs font-bold">
|
<span className="text-[10px] sm:text-xs font-bold">
|
||||||
{onlineCount}
|
{onlineCount}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: grip handle explicitly initiates a drag (mouse+touch) and is not a button */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
borderColor:
|
border: "1px solid rgba(255,255,255,0.18)",
|
||||||
"color-mix(in srgb, var(--color-primary, #eeb425) 75%, black)",
|
background: "rgba(255,255,255,0.06)",
|
||||||
background:
|
color: "rgba(255,255,255,0.85)",
|
||||||
"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"
|
title={tToolbar("dragToMove")}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
@@ -348,9 +488,9 @@ export function ClientView({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
role="img"
|
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="9" cy="5" r="1" />
|
||||||
<circle cx="15" cy="5" r="1" />
|
<circle cx="15" cy="5" r="1" />
|
||||||
<circle cx="9" cy="12" r="1" />
|
<circle cx="9" cy="12" r="1" />
|
||||||
@@ -361,14 +501,75 @@ export function ClientView({
|
|||||||
</div>
|
</div>
|
||||||
<ToolbarBtn
|
<ToolbarBtn
|
||||||
onClick={() => void signOutAndRevokeTicket()}
|
onClick={() => void signOutAndRevokeTicket()}
|
||||||
title="Logout"
|
label={tToolbar("logout")}
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />
|
||||||
</ToolbarBtn>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
className="relative w-screen h-screen overflow-hidden"
|
className="relative w-screen h-screen overflow-hidden"
|
||||||
style={{ backgroundColor: "var(--color-background)" }}
|
style={{ backgroundColor: "var(--color-background)" }}
|
||||||
|
|||||||
@@ -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 })}</>;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export async function SiteHeader() {
|
export async function SiteHeader() {
|
||||||
const t = await getTranslations("header");
|
|
||||||
const [hotelName, header, logo] = await Promise.all([
|
const [hotelName, header, logo] = await Promise.all([
|
||||||
resolveHotelName(),
|
resolveHotelName(),
|
||||||
siteSettings.get("cms_header", "/assets/images/background.png"),
|
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"
|
className="text-xs sm:text-sm font-bold tracking-wide"
|
||||||
style={{ color: "rgba(255,255,255,0.9)" }}
|
style={{ color: "rgba(255,255,255,0.9)" }}
|
||||||
>
|
>
|
||||||
{t("online", { count: online, hotel: hotelName })}
|
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { count, eq, inArray } from "drizzle-orm";
|
import { count, eq, inArray } from "drizzle-orm";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
import { signOutAndRevokeTicket } from "@/actions/sessions";
|
||||||
import { HeaderUserIdentity } from "@/components/header-user-identity";
|
import { HeaderUserIdentity } from "@/components/header-user-identity";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
@@ -100,7 +100,6 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
|||||||
const textColor =
|
const textColor =
|
||||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||||
|
|
||||||
const t = await getTranslations("header");
|
|
||||||
const hotelName = await resolveHotelName();
|
const hotelName = await resolveHotelName();
|
||||||
|
|
||||||
let online: number;
|
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"
|
className="text-[10px] sm:text-sm font-bold tracking-wide"
|
||||||
style={{ color: "rgba(255,255,255,0.9)" }}
|
style={{ color: "rgba(255,255,255,0.9)" }}
|
||||||
>
|
>
|
||||||
{t("online", { count: online, hotel: hotelName })}
|
<LiveOnlineCount initial={online} hotelName={hotelName} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,18 @@
|
|||||||
"studio": "Studio"
|
"studio": "Studio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"home": "الرئيسية",
|
||||||
|
"reload": "إعادة تحميل",
|
||||||
|
"fullscreen": "ملء الشاشة",
|
||||||
|
"logout": "تسجيل الخروج",
|
||||||
|
"onlineUsers": "المستخدمون المتواجدون",
|
||||||
|
"emulatorOnline": "المحاكي متصل",
|
||||||
|
"emulatorOffline": "المحاكي غير متصل",
|
||||||
|
"dragToMove": "اسحب للتحريك",
|
||||||
|
"hide": "إخفاء الشريط",
|
||||||
|
"fullscreenExit": "الخروج من ملء الشاشة",
|
||||||
|
"show": "إظهار الشريط"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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": "Покажи лентата"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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": "Εμφάνιση γραμμής"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,18 @@
|
|||||||
"studio": "Studio"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,18 @@
|
|||||||
"studio": "Studio"
|
"studio": "Studio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"home": "ホーム",
|
||||||
|
"reload": "再読み込み",
|
||||||
|
"fullscreen": "全画面",
|
||||||
|
"logout": "ログアウト",
|
||||||
|
"onlineUsers": "オンラインのユーザー",
|
||||||
|
"emulatorOnline": "エミュレータオンライン",
|
||||||
|
"emulatorOffline": "エミュレータオフライン",
|
||||||
|
"dragToMove": "ドラッグして移動",
|
||||||
|
"hide": "ツールバーを隠す",
|
||||||
|
"fullscreenExit": "全画面を終了",
|
||||||
|
"show": "ツールバーを表示"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3261,5 +3261,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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": "Показать панель"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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": "Прикажи траку"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3263,5 +3263,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3262,5 +3262,18 @@
|
|||||||
"button": "Verify email",
|
"button": "Verify email",
|
||||||
"fallback": "If the button doesn't work, paste this link into your browser:"
|
"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": "Показати панель"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user