From 2a2daefd8c160709b20ebc488b1d83780b0357d0 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 2 Jan 2025 13:33:36 -0600 Subject: [PATCH] DownloadButton progress --- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 157 ++++++++++++++++++ frontend/src/components/Demo.tsx | 20 ++- frontend/src/components/DownloadButton.tsx | 177 +++++++++++++++++++++ frontend/tailwind.config.mjs | 13 ++ 5 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/DownloadButton.tsx diff --git a/frontend/package.json b/frontend/package.json index e7dc9cf..e9b3ca7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1b9d6d5..938d292 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: diff --git a/frontend/src/components/Demo.tsx b/frontend/src/components/Demo.tsx index 6acb1c1..9cc148b 100644 --- a/frontend/src/components/Demo.tsx +++ b/frontend/src/components/Demo.tsx @@ -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(null); const highlightedTimeoutRef = useRef(null); @@ -29,8 +32,8 @@ const Demo = ({ class: className }: DemoProps) => { } return ( -
-

+

+

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) => { {" "} known {plural("download", downloads?.length ?? 0)}.

-
+
+ {downloads?.map((download, i) => ( { ))}
-
-

+

+

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. diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx new file mode 100644 index 0000000..66fde31 --- /dev/null +++ b/frontend/src/components/DownloadButton.tsx @@ -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(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 ( +

*]: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" + )} + > + + + + + + + {executables?.map((executable) => ( + + + + ))} + {buildLog != null ? ( + <> + + + + + + ) : null} + + +
+ ); +} diff --git a/frontend/tailwind.config.mjs b/frontend/tailwind.config.mjs index 591aeba..3ebe2d0 100644 --- a/frontend/tailwind.config.mjs +++ b/frontend/tailwind.config.mjs @@ -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"],