diff --git a/packages/web/src/pages/ScriptsPage.jsx b/packages/web/src/pages/ScriptsPage.jsx index ff8fc71..fa5bbad 100644 --- a/packages/web/src/pages/ScriptsPage.jsx +++ b/packages/web/src/pages/ScriptsPage.jsx @@ -1,12 +1,13 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; -import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu"; +import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2, LuUpload } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { useDeleteScript, useDuplicatePlugin, useForkScript, useInstallPlugin, + useMe, useScripts, } from "../api/hooks.js"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; @@ -15,6 +16,21 @@ import { TagBadge } from "../components/TagBadge.jsx"; import { scriptTags } from "../lib/script.js"; import { useNotifications } from "../notifications.jsx"; +const BASE64_CHUNK = 0x8000; + +function uint8ArrayToBase64(bytes) { + let binary = ""; + for (let i = 0; i < bytes.length; i += BASE64_CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK)); + } + return btoa(binary); +} + +async function fileToBase64(file) { + const bytes = new Uint8Array(await file.arrayBuffer()); + return uint8ArrayToBase64(bytes); +} + export function ScriptsPage() { const [params] = useSearchParams(); const navigate = useNavigate(); @@ -30,7 +46,13 @@ export function ScriptsPage() { const fork = useForkScript(); const duplicate = useDuplicatePlugin(); const install = useInstallPlugin(); + const me = useMe(); + const isAdmin = me.data?.user?.role === "admin"; const { notify } = useNotifications(); + const [pendingZip, setPendingZip] = useState(null); + const [zipEncoding, setZipEncoding] = useState(false); + const zipInFlight = useRef(false); + const zipBusy = zipEncoding || install.isPending; const visible = useMemo(() => { const term = query.trim().toLowerCase(); @@ -49,6 +71,44 @@ export function ScriptsPage() { }); }, [scripts, query]); + function onPickZip(e) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + install.reset(); + setPendingZip(file); + } + + async function installPendingZip() { + if (!pendingZip || zipInFlight.current) return; + zipInFlight.current = true; + setZipEncoding(true); + try { + const zipBase64 = await fileToBase64(pendingZip); + install.mutate( + { source: "zip", zipBase64, overwrite: true }, + { + onSuccess: (data) => { + setPendingZip(null); + const ref = + data.scriptRef ?? (data.id ? `plugin/${data.id}` : "plugin"); + notify.success(`Installed ${ref} — drain-restart to load workers`); + if (data.warning) notify.warning(data.warning); + }, + onError: (err) => notify.error(errorMessage(err)), + onSettled: () => { + zipInFlight.current = false; + setZipEncoding(false); + }, + }, + ); + } catch (err) { + zipInFlight.current = false; + setZipEncoding(false); + notify.error(errorMessage(err)); + } + } + if (editName) { return ; } @@ -71,7 +131,7 @@ export function ScriptsPage() { + {isAdmin ? ( + + ) : null} Add plugin @@ -341,6 +418,30 @@ export function ScriptsPage() { ) : null} + + Installing{" "} + {pendingZip.name} replaces an + existing plugin with the same id. Drain-restart workers after + install so new dependencies load. + + ) : null + } + confirmLabel="Install" + confirmClass="btn-primary" + error={install.isError ? errorMessage(install.error) : null} + loading={zipBusy} + onCancel={() => { + if (zipBusy) return; + setPendingZip(null); + }} + onConfirm={installPendingZip} + /> +