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}
+ />
+