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

@@ -12,6 +12,8 @@
"@astrojs/react": "^4.1.2",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^5.1.4",
"@headlessui/react": "^2.2.0",
"@heroicons/react": "^2.2.0",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",

157
frontend/pnpm-lock.yaml generated
View File

@@ -17,6 +17,12 @@ importers:
'@astrojs/tailwind':
specifier: ^5.1.4
version: 5.1.4(astro@5.1.1(jiti@2.4.2)(rollup@4.29.1)(sass-embedded@1.83.0)(typescript@5.7.2)(yaml@2.6.1))(tailwindcss@3.4.17)
'@headlessui/react':
specifier: ^2.2.0
version: 2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.0.0)
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.17)
@@ -470,9 +476,33 @@ packages:
'@floating-ui/dom@1.6.12':
resolution: {integrity: sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==}
'@floating-ui/react-dom@2.1.2':
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
'@headlessui/react@2.2.0':
resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -707,6 +737,37 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@react-aria/focus@3.19.0':
resolution: {integrity: sha512-hPF9EXoUQeQl1Y21/rbV2H4FdUR2v+4/I0/vB+8U3bT1CJ+1AFj1hc/rqx2DqEwDlEwOHN+E4+mRahQmlybq0A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.22.5':
resolution: {integrity: sha512-kMwiAD9E0TQp+XNnOs13yVJghiy8ET8L0cbkeuTgNI96sOAp/63EJ1FSrDf17iD8sdjt41LafwX/dKXW9nCcLQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.7':
resolution: {integrity: sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.26.0':
resolution: {integrity: sha512-LkZouGSjjQ0rEqo4XJosS4L3YC/zzQkfRM3KoqK6fUOmUJ9t0jQ09WjiF+uOoG9u+p30AVg3TrZRUWmoTS+koQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/utils@3.10.5':
resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.26.0':
resolution: {integrity: sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
engines: {node: '>=14.0.0'}
@@ -826,11 +887,23 @@ packages:
'@shikijs/vscode-textmate@9.3.1':
resolution: {integrity: sha512-79QfK1393x9Ho60QFyLti+QfdJzRQCVLFb97kOIV7Eo9vQU/roINgk7m24uv0a7AUvN//RDH36FLjjK48v0s9g==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/typography@0.5.15':
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
'@tanstack/react-virtual@3.11.2':
resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.11.2':
resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2213,6 +2286,9 @@ packages:
resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==}
engines: {node: '>=18'}
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tailwind-merge@2.5.5:
resolution: {integrity: sha512-0LXunzzAZzo0tEPxV3I297ffKZPlKDrjj7NXphC8V5ak9yHC5zRmxnOe2m/Rd/7ivsOMJe3JZ2JVocoDdQTRBA==}
@@ -2878,8 +2954,35 @@ snapshots:
'@floating-ui/core': 1.6.8
'@floating-ui/utils': 0.2.8
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.6.12
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/utils': 0.2.8
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tabbable: 6.2.0
'@floating-ui/utils@0.2.8': {}
'@headlessui/react@2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-aria/focus': 3.19.0(react@19.0.0)
'@react-aria/interactions': 3.22.5(react@19.0.0)
'@tanstack/react-virtual': 3.11.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@heroicons/react@2.2.0(react@19.0.0)':
dependencies:
react: 19.0.0
'@img/sharp-darwin-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
@@ -3063,6 +3166,46 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@react-aria/focus@3.19.0(react@19.0.0)':
dependencies:
'@react-aria/interactions': 3.22.5(react@19.0.0)
'@react-aria/utils': 3.26.0(react@19.0.0)
'@react-types/shared': 3.26.0(react@19.0.0)
'@swc/helpers': 0.5.15
clsx: 2.1.1
react: 19.0.0
'@react-aria/interactions@3.22.5(react@19.0.0)':
dependencies:
'@react-aria/ssr': 3.9.7(react@19.0.0)
'@react-aria/utils': 3.26.0(react@19.0.0)
'@react-types/shared': 3.26.0(react@19.0.0)
'@swc/helpers': 0.5.15
react: 19.0.0
'@react-aria/ssr@3.9.7(react@19.0.0)':
dependencies:
'@swc/helpers': 0.5.15
react: 19.0.0
'@react-aria/utils@3.26.0(react@19.0.0)':
dependencies:
'@react-aria/ssr': 3.9.7(react@19.0.0)
'@react-stately/utils': 3.10.5(react@19.0.0)
'@react-types/shared': 3.26.0(react@19.0.0)
'@swc/helpers': 0.5.15
clsx: 2.1.1
react: 19.0.0
'@react-stately/utils@3.10.5(react@19.0.0)':
dependencies:
'@swc/helpers': 0.5.15
react: 19.0.0
'@react-types/shared@3.26.0(react@19.0.0)':
dependencies:
react: 19.0.0
'@rollup/pluginutils@5.1.4(rollup@4.29.1)':
dependencies:
'@types/estree': 1.0.6
@@ -3155,6 +3298,10 @@ snapshots:
'@shikijs/vscode-textmate@9.3.1': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.17)':
dependencies:
lodash.castarray: 4.4.0
@@ -3163,6 +3310,14 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17
'@tanstack/react-virtual@3.11.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@tanstack/virtual-core': 3.11.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@tanstack/virtual-core@3.11.2': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.26.3
@@ -4892,6 +5047,8 @@ snapshots:
system-architecture@0.1.0: {}
tabbable@6.2.0: {}
tailwind-merge@2.5.5: {}
tailwindcss@3.4.17:

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>
);
}

View File

@@ -3,6 +3,19 @@ export default {
content: ["./src/**/*.{astro,html,js,jsx,ts,tsx}"],
theme: {
extend: {
animation: {
"pulse-dark": "pulse-dark 2.5s ease-in-out infinite",
},
keyframes: {
"pulse-dark": {
"0%, 100%": {
backgroundColor: "#0A3026",
},
"50%": {
backgroundColor: "#053B2D",
},
},
},
fontFamily: {
bebas: ["Bebas Neue", "sans-serif"],
inter: ["Inter", "sans-serif"],