DownloadButton progress

This commit is contained in:
2025-01-02 13:33:36 -06:00
parent 5f2dcfa5c9
commit 2a2daefd8c
5 changed files with 363 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import Badge from "@/components/Badge";
import DownloadButton from "@/components/DownloadButton";
import Emboldened from "@/components/Emboldened";
import useSocket from "@/components/useSocket";
import { cn, plural, toHex, type ClassValue } from "@/util";
@@ -9,9 +10,11 @@ type DemoProps = {
};
const Demo = ({ class: className }: DemoProps) => {
const { id, downloads } = useSocket();
const { id, downloads, executables } = useSocket();
// TODO: Toasts
console.log([executables == null]);
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
const highlightedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -29,8 +32,8 @@ const Demo = ({ class: className }: DemoProps) => {
}
return (
<div class={cn(className, "px-5 leading-6")}>
<p class="mt-3 mb-3">
<div className={cn(className, "px-5 leading-6")}>
<p className="mt-3 mb-3">
This demo uses websockets to communicate between the server and the
browser. Each download gets a unique identifier bound to the user
session.
@@ -45,7 +48,12 @@ const Demo = ({ class: className }: DemoProps) => {
</Emboldened>{" "}
known {plural("download", downloads?.length ?? 0)}.
</p>
<div class="flex flex-wrap justify-center gap-y-2.5">
<div className="flex flex-wrap justify-center gap-y-2.5">
<DownloadButton
disabled={executables == null}
buildLog={"https://railway.com"}
executables={executables}
/>
{downloads?.map((download, i) => (
<Badge
className={cn(
@@ -65,8 +73,8 @@ const Demo = ({ class: className }: DemoProps) => {
</Badge>
))}
</div>
<div class="mt-4 p-2 bg-zinc-900/90 rounded-md border border-zinc-700">
<p class="my-0">
<div className="mt-4 p-2 bg-zinc-900/90 rounded-md border border-zinc-700">
<p className="my-0">
The server running this is completely ephemeral, can restart at any
time, and purges data on regular intervals - at which point the
executables you've downloaded will no longer function.

View File

@@ -0,0 +1,177 @@
"use client";
import type { Executable } from "@/components/useSocket";
import { cn, withBackend } from "@/util";
import {
Button,
Menu,
MenuButton,
MenuItem,
MenuItems,
MenuSeparator,
} from "@headlessui/react";
import {
ArrowDownTrayIcon,
BeakerIcon,
ChevronDownIcon,
} from "@heroicons/react/16/solid";
import { useRef } from "react";
type DownloadButtonProps = {
disabled?: boolean;
executables: Executable[] | null;
buildLog: string | null;
};
type SystemType = "windows" | "macos" | "linux";
function getSystemType(): SystemType | null {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("win")) {
return "windows";
} else if (userAgent.includes("mac")) {
return "macos";
} else if (userAgent.includes("linux")) {
return "linux";
} else {
return null;
}
}
export default function DownloadButton({
disabled,
executables,
buildLog,
}: DownloadButtonProps) {
const menuRef = useRef<HTMLButtonElement>(null);
console.log({ disabled });
function getExecutable(id: string) {
return executables?.find((e) => e.id.toLowerCase() === id.toLowerCase());
}
async function handleDownload(id: string) {
const executable = getExecutable(id);
if (executable == null) {
console.error(`Executable ${id} not found, cannot download`);
return;
}
try {
const response = await fetch(withBackend(`/download/${executable.id}`), {
method: "GET",
headers: {
"Content-Type": "application/octet-stream",
},
});
if (!response.ok) {
if (response.headers.get("Content-Type") === "application/json") {
const json = await response.json();
console.error("Download failed", json);
} else {
console.error(
"Download failed (unreadable response)",
response.statusText
);
}
}
// Create blob link to download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// Create a link element and click it to download the file
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", executable.filename);
document.body.appendChild(link);
link.click();
link.parentNode!.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Download failed", error);
}
}
function handleDownloadAutomatic() {
const systemType = getSystemType();
// If the system type is unknown/unavailable, open the menu for manual selection
if (systemType == null || getExecutable(systemType) == null) {
menuRef.current?.click();
}
// Otherwise, download the executable automatically
else {
handleDownload(systemType);
}
}
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={handleDownloadAutomatic}
disabled={disabled}
className={cn("pl-3 font-semibold pr-2.5", {
"hover:bg-white/5": !disabled,
})}
>
Download
</Button>
<Menu>
<MenuButton
ref={menuRef}
disabled={disabled ?? false}
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"
className="w-40 z-20 mt-1 origin-top-right rounded-xl border border-white/[0.08] bg-zinc-900 shadow-md p-1 text-sm/6 text-zinc-200 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
>
{executables?.map((executable) => (
<MenuItem key={executable.id}>
<button
className="group flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pl-2 pr-2.5 data-[focus]:bg-white/10"
onClick={() => handleDownload(executable.id)}
>
<div className="flex items-center gap-1.5">
<ArrowDownTrayIcon className="size-4 fill-white/40" />
{executable.id}
</div>
<div className="text-xs text-zinc-500">
{(executable.size / 1024 / 1024).toFixed(1)} MiB
</div>
</button>
</MenuItem>
))}
{buildLog != null ? (
<>
<MenuSeparator className="my-1 h-px bg-white/10" />
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-2 data-[focus]:bg-white/10">
<BeakerIcon className="size-4 fill-white/40" />
Build Logs
</button>
</MenuItem>
</>
) : null}
</MenuItems>
</Menu>
</div>
);
}