feat(frontend): add mobile device detection and warning modal

Add mobile device detection to prevent confusion when users try to download desktop applications on mobile devices. The download button now displays "Download for Desktop" on mobile and shows a warning modal explaining that the executables are for desktop platforms only. The warning is shown once per session and can be acknowledged to proceed with viewing download options.
This commit is contained in:
2025-12-11 18:18:55 -06:00
parent b4022ff9db
commit 4a191a59f4
3 changed files with 153 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import type { Executable } from "@/components/useSocket";
import { cn, withBackend } from "@/util";
import MobileWarningModal from "@/components/MobileWarningModal";
import { cn, isMobile, withBackend } from "@/util";
import {
Button,
Menu,
@@ -13,7 +14,9 @@ import {
ChevronDownIcon,
} from "@heroicons/react/16/solid";
import { FaWindows, FaApple, FaLinux } from "react-icons/fa";
import { useRef } from "react";
import { useRef, useState } from "react";
const MOBILE_WARNING_KEY = "mobile-warning-acknowledged";
type DownloadButtonProps = {
disabled?: boolean;
@@ -70,15 +73,44 @@ export default function DownloadButton({
buildLog,
}: DownloadButtonProps) {
const menuRef = useRef<HTMLButtonElement>(null);
const [showMobileWarning, setShowMobileWarning] = useState(false);
const [mobileAcknowledged, setMobileAcknowledged] = useState(() => {
if (typeof window === "undefined") return false;
return sessionStorage.getItem(MOBILE_WARNING_KEY) === "true";
});
function getExecutable(id: string) {
return executables?.find((e) => e.id.toLowerCase() === id.toLowerCase());
}
const detectedPlatform = getSystemType();
const mobile = isMobile();
const detectedPlatform = mobile ? null : getSystemType();
const platformExecutable = detectedPlatform ? getExecutable(detectedPlatform) : null;
const canAutoDownload = platformExecutable != null;
function acknowledgeMobileWarning() {
sessionStorage.setItem(MOBILE_WARNING_KEY, "true");
setMobileAcknowledged(true);
}
function handleMobileButtonClick() {
if (!mobileAcknowledged) {
setShowMobileWarning(true);
} else {
menuRef.current?.click();
}
}
function handleMobileWarningClose() {
setShowMobileWarning(false);
}
function handleMobileWarningContinue() {
acknowledgeMobileWarning();
setShowMobileWarning(false);
menuRef.current?.click();
}
async function handleDownload(id: string) {
const executable = getExecutable(id);
if (executable == null) {
@@ -97,39 +129,65 @@ export default function DownloadButton({
}
return (
<div
className={cn(
"[&>*]:py-1 overflow-clip transition-[background-color] text-sm/6 flex items-center shadow-inner align-middle text-white focus:outline-none data-[focus]:outline-1 data-[focus]:outline-white",
!disabled
? "divide-white/[0.2] shadow-white/10 bg-emerald-800 data-[hover]:bg-emerald-700 data-[open]:bg-emerald-700"
: "divide-white/[0.1] shadow-white/5 animate-pulse-dark data-[hover]:bg-[#064e3b] cursor-wait",
"rounded-md divide-x h-full rounded-l-md"
)}
>
<Button
onClick={canAutoDownload ? handleDownloadAutomatic : undefined}
suppressHydrationWarning
disabled={disabled || !canAutoDownload}
className={cn("pl-3 font-semibold pr-2.5", {
"hover:bg-white/5 cursor-pointer": !disabled && canAutoDownload,
"cursor-default": !canAutoDownload,
})}
<>
<MobileWarningModal
open={showMobileWarning}
onClose={handleMobileWarningClose}
onContinue={handleMobileWarningContinue}
/>
<div
className={cn(
"[&>*]:py-1 overflow-clip transition-[background-color] text-sm/6 flex items-center shadow-inner align-middle text-white focus:outline-none data-[focus]:outline-1 data-[focus]:outline-white",
!disabled
? "divide-white/[0.2] shadow-white/10 bg-emerald-800 data-[hover]:bg-emerald-700 data-[open]:bg-emerald-700"
: "divide-white/[0.1] shadow-white/5 animate-pulse-dark data-[hover]:bg-[#064e3b] cursor-wait",
"rounded-md divide-x h-full rounded-l-md"
)}
>
{canAutoDownload && detectedPlatform
? `Download for ${getPlatformDisplayName(detectedPlatform)}`
: "Download"}
</Button>
<Menu>
<MenuButton
ref={menuRef}
<Button
onClick={
mobile
? handleMobileButtonClick
: canAutoDownload
? handleDownloadAutomatic
: undefined
}
suppressHydrationWarning
disabled={disabled}
className={cn("pl-1.5 text-transparent min-h-8 pr-2", {
"hover:bg-white/5": !disabled,
disabled={disabled || (!mobile && !canAutoDownload)}
className={cn("pl-3 font-semibold pr-2.5", {
"hover:bg-white/5 cursor-pointer": !disabled && (mobile || canAutoDownload),
"cursor-default": !mobile && !canAutoDownload,
})}
>
<ChevronDownIcon className="size-4 fill-white/60" />
</MenuButton>
{mobile
? "Download for Desktop"
: canAutoDownload && detectedPlatform
? `Download for ${getPlatformDisplayName(detectedPlatform)}`
: "Download"}
</Button>
<Menu>
{mobile && !mobileAcknowledged ? (
<button
onClick={handleMobileButtonClick}
disabled={disabled}
className={cn("pl-1.5 min-h-8 pr-2 py-1", {
"hover:bg-white/5": !disabled,
})}
>
<ChevronDownIcon className="size-4 fill-white/60" />
</button>
) : (
<MenuButton
ref={menuRef}
suppressHydrationWarning
disabled={disabled}
className={cn("pl-1.5 text-transparent min-h-8 pr-2", {
"hover:bg-white/5": !disabled,
})}
>
<ChevronDownIcon className="size-4 fill-white/60" />
</MenuButton>
)}
<MenuItems
transition
anchor="bottom end"
@@ -169,5 +227,6 @@ export default function DownloadButton({
</MenuItems>
</Menu>
</div>
</>
);
}

View File

@@ -0,0 +1,57 @@
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
type MobileWarningModalProps = {
open: boolean;
onClose: () => void;
onContinue: () => void;
};
export default function MobileWarningModal({
open,
onClose,
onContinue,
}: MobileWarningModalProps) {
return (
<Dialog open={open} onClose={onClose} className="relative z-50">
<DialogBackdrop
transition
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
transition
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-900 p-5 shadow-xl transition-all duration-200 data-[closed]:scale-95 data-[closed]:opacity-0"
>
<div className="flex items-center gap-3 mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<ExclamationTriangleIcon className="h-5 w-5 text-amber-400" />
</div>
<DialogTitle className="text-lg font-semibold text-zinc-100">
Heads up!
</DialogTitle>
</div>
<p className="text-sm text-zinc-300 leading-relaxed mb-4">
These downloads are desktop applications for Windows, macOS, and
Linux. They won't run on mobile devices, but you're welcome to
download them to transfer to a computer later.
</p>
<button
onClick={onContinue}
className="w-full rounded-lg bg-emerald-700 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-zinc-900"
>
Got it, continue
</button>
</DialogPanel>
</div>
</Dialog>
);
}

View File

@@ -19,6 +19,11 @@ export function os(): Platform | "other" {
return "other";
}
export function isMobile(): boolean {
const ua = navigator.userAgent.toLowerCase();
return /android|iphone|ipad|ipod|webos|blackberry|windows phone/.test(ua);
}
export function toHex(value: number): string {
return "0x" + value.toString(16).toUpperCase();
}