Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 134 additions & 11 deletions client/app/src/components/views/Title.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"

import { AnimatePresence, motion } from "motion/react"
import { SpeakerHighIcon, SpeakerSlashIcon } from "@phosphor-icons/react"
import { cva } from "class-variance-authority"

import TitleVideo from "@/assets/videos/title.mp4"
import { CharacterSelectDialog } from "@/components/dialogs/CharacterSelect"
import { IntroTutorial } from "@/components/dialogs/IntroTutorial"
import { Leaderboard } from "@/components/dialogs/Leaderboard"
import { Settings } from "@/components/dialogs/Settings"
import PipecatSVG from "@/components/PipecatSVG"
import { Badge, BadgeTitle } from "@/components/primitives/Badge"
import { Button } from "@/components/primitives/Button"
import { Card, CardContent, CardHeader } from "@/components/primitives/Card"
import { Input } from "@/components/primitives/Input"
Expand All @@ -17,6 +19,43 @@ import { ScrambleText } from "@/fx/ScrambleText"
import useAudioStore from "@/stores/audio"
import useGameStore from "@/stores/game"
import { wait } from "@/utils/animation"
import {
buildServerFunctionUrl,
getLoginScreenServerBadge,
parsePublicServerStatus,
type LoginScreenServerState,
} from "@/utils/serverStatus"

const SERVER_STATUS_REFRESH_MS = 30_000

const serverStatusBadgeStyles = cva("mx-auto min-w-[14rem] justify-center text-center", {
variants: {
state: {
checking: "bg-muted/60 border-border text-subtle-foreground bracket-muted-foreground",
online: "bg-success-background border-success text-success-foreground bracket-success",
maintenance: "bg-warning-background border-warning text-warning-foreground bracket-warning",
offline:
"bg-destructive-background/60 border-destructive/60 text-destructive bracket-destructive",
},
},
defaultVariants: {
state: "checking",
},
})

const serverStatusDotStyles = cva("size-2 rounded-full", {
variants: {
state: {
checking: "bg-subtle-foreground animate-pulse",
online: "bg-success animate-pulse",
maintenance: "bg-warning animate-pulse",
offline: "bg-destructive",
},
},
defaultVariants: {
state: "checking",
},
})

export const Title = ({ onViewNext }: { onViewNext: () => void }) => {
const setActiveModal = useGameStore.use.setActiveModal()
Expand All @@ -30,7 +69,78 @@ export const Title = ({ onViewNext }: { onViewNext: () => void }) => {
const [error, setError] = useState<string | null>(null)
const [isMusicPlaying, setIsMusicPlaying] = useState<boolean>(false)
const [hasInteractedWithMusic, setHasInteractedWithMusic] = useState<boolean>(false)
const [serverStatus, setServerStatus] = useState<{
state: LoginScreenServerState
message: string
}>({
state: "checking",
message: "",
})
const titleVideoRef = useRef<HTMLVideoElement>(null)
const serverStatusBadge = getLoginScreenServerBadge(serverStatus.state, serverStatus.message)

useEffect(() => {
let disposed = false
let activeController: AbortController | null = null

const refreshServerStatus = async () => {
activeController?.abort()
const controller = new AbortController()
activeController = controller

try {
const response = await fetch(
buildServerFunctionUrl("server_status", import.meta.env.VITE_SERVER_URL),
{
method: "GET",
headers: {
Accept: "application/json",
},
signal: controller.signal,
}
)
const data = await response.json().catch(() => null)

if (!response.ok) {
throw new Error(
data && typeof data === "object" && "error" in data && typeof data.error === "string"
? data.error
: "Unable to reach the public status endpoint."
)
}

const parsed = parsePublicServerStatus(data)
if (!parsed) {
throw new Error("Unexpected response from the public status endpoint.")
}

if (!disposed) {
setServerStatus(parsed)
}
} catch (err) {
if (controller.signal.aborted || disposed) {
return
}

setServerStatus({
state: "offline",
message:
err instanceof Error ? err.message : "Unable to reach the public status endpoint.",
})
}
}

void refreshServerStatus()
const intervalId = window.setInterval(() => {
void refreshServerStatus()
}, SERVER_STATUS_REFRESH_MS)

return () => {
disposed = true
activeController?.abort()
window.clearInterval(intervalId)
}
}, [])

const startMusic = () => {
setIsMusicPlaying(true)
Expand All @@ -46,16 +156,13 @@ export const Title = ({ onViewNext }: { onViewNext: () => void }) => {
const handleSignIn = async () => {
setIsLoading(true)
try {
const response = await fetch(
`${import.meta.env.VITE_SERVER_URL || "http://localhost:54321/functions/v1"}/login`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: username, password }),
}
)
const response = await fetch(buildServerFunctionUrl("login", import.meta.env.VITE_SERVER_URL), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: username, password }),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(data?.error || "Failed to sign in")
Expand Down Expand Up @@ -116,6 +223,22 @@ export const Title = ({ onViewNext }: { onViewNext: () => void }) => {
<h1 className="text-white text-3xl font-bold uppercase text-center">
<ScrambleText>Gradient Bang</ScrambleText>
</h1>
<div className="mt-4 flex flex-col items-center gap-2">
<Badge
border="bracket"
size="sm"
aria-label={serverStatusBadge.label}
className={serverStatusBadgeStyles({ state: serverStatusBadge.state })}
>
<span className={serverStatusDotStyles({ state: serverStatusBadge.state })} />
<BadgeTitle>{serverStatusBadge.label}</BadgeTitle>
</Badge>
{serverStatusBadge.detail && (
<p className="max-w-sm text-center text-xs text-subtle-foreground">
{serverStatusBadge.detail}
</p>
)}
</div>
</CardHeader>
<Separator />
<CardContent className="flex flex-col items-center justify-center gap-5">
Expand Down
79 changes: 79 additions & 0 deletions client/app/src/utils/serverStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type PublicServerStatus = "online" | "maintenance";
export type LoginScreenServerState =
| "checking"
| "online"
| "maintenance"
| "offline";

export interface ParsedPublicServerStatus {
state: Extract<LoginScreenServerState, "online" | "maintenance">;
message: string;
}

export interface LoginScreenServerBadge {
state: LoginScreenServerState;
label: string;
detail: string;
}

const DEFAULT_SERVER_URL = "http://localhost:54321/functions/v1";

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function buildServerFunctionUrl(functionName: string, baseUrl?: string | null): string {
const normalizedBase =
(baseUrl?.trim() || DEFAULT_SERVER_URL).replace(/\/+$/, "");
const normalizedFunction = functionName.replace(/^\/+/, "");
return `${normalizedBase}/${normalizedFunction}`;
}

export function parsePublicServerStatus(value: unknown): ParsedPublicServerStatus | null {
if (!isRecord(value) || value.success !== true) {
return null;
}

if (value.status !== "online" && value.status !== "maintenance") {
return null;
}

return {
state: value.status,
message: typeof value.message === "string" ? value.message : "",
};
}

export function getLoginScreenServerBadge(
state: LoginScreenServerState,
message?: string | null,
): LoginScreenServerBadge {
const detail = message?.trim() ?? "";

switch (state) {
case "online":
return {
state,
label: "Server Online",
detail,
};
case "maintenance":
return {
state,
label: "Maintenance",
detail || "Login is temporarily unavailable.",
};
case "offline":
return {
state,
label: "Server Unreachable",
detail || "Unable to reach the public status endpoint.",
};
case "checking":
return {
state,
label: "Checking Server",
detail: "Checking the public status endpoint.",
};
}
}
40 changes: 40 additions & 0 deletions deployment/supabase/functions/_shared/server_status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const DEFAULT_MAINTENANCE_MESSAGE =
"Gradient Bang is down for maintenance. Please try again shortly.";
export const DEFAULT_ONLINE_STATUS_MESSAGE =
"Gradient Bang login services are available.";

export type PublicServerStatus = "online" | "maintenance";

export interface PublicServerStatusSnapshot {
status: PublicServerStatus;
can_login: boolean;
message: string;
}

export function isMaintenanceMode(raw = Deno.env.get("MAINTENANCE_MODE")): boolean {
const normalized = (raw ?? "").trim().toLowerCase();
return normalized !== "" && normalized !== "0" && normalized !== "false";
}

export function getMaintenanceMessage(
raw = Deno.env.get("MAINTENANCE_MESSAGE"),
): string {
const trimmed = raw?.trim();
return trimmed ? trimmed : DEFAULT_MAINTENANCE_MESSAGE;
}

export function getPublicServerStatusSnapshot(): PublicServerStatusSnapshot {
if (isMaintenanceMode()) {
return {
status: "maintenance",
can_login: false,
message: getMaintenanceMessage(),
};
}

return {
status: "online",
can_login: true,
message: DEFAULT_ONLINE_STATUS_MESSAGE,
};
}
13 changes: 5 additions & 8 deletions deployment/supabase/functions/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
requireString,
respondWithError,
} from "../_shared/request.ts";
import {
getMaintenanceMessage,
isMaintenanceMode,
} from "../_shared/server_status.ts";
import { traced } from "../_shared/weave.ts";

// CORS headers for public access from web clients
Expand All @@ -42,21 +46,14 @@ function corsResponse(body: unknown, status = 200): Response {
});
}

function isMaintenanceMode(): boolean {
const raw = (Deno.env.get("MAINTENANCE_MODE") ?? "").trim().toLowerCase();
return raw !== "" && raw !== "0" && raw !== "false";
}

Deno.serve(traced("login", async (req, trace) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}

if (isMaintenanceMode()) {
const message =
Deno.env.get("MAINTENANCE_MESSAGE")?.trim() ||
"Gradient Bang is down for maintenance. Please try again shortly.";
const message = getMaintenanceMessage();
return corsResponse(
{ success: false, error: message, code: "maintenance_mode" },
503,
Expand Down
45 changes: 45 additions & 0 deletions deployment/supabase/functions/server_status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Public Edge Function: server_status
*
* Returns a browser-safe status snapshot for the login screen.
* No EDGE_API_TOKEN required.
*/

import { getPublicServerStatusSnapshot } from "../_shared/server_status.ts";
import { traced } from "../_shared/weave.ts";

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};

function corsJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}

Deno.serve(traced("server_status", async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}

if (req.method !== "GET" && req.method !== "POST") {
return corsJsonResponse(
{ success: false, error: "Method not allowed" },
405,
);
}

return corsJsonResponse({
success: true,
...getPublicServerStatusSnapshot(),
});
}));
Loading