feat(scripts): add zip file installation functionality for plugins
- Introduced the ability to install plugins from zip files, enhancing the script management interface. - Implemented file selection and base64 encoding for zip files before installation. - Added confirmation dialog for installing plugins from zip, including warnings for existing plugins. - Integrated user role checks to restrict zip installation to admin users only. - Improved UI feedback during the installation process with loading indicators.
This commit is contained in:
@@ -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 <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||
}
|
||||
@@ -71,7 +131,7 @@ export function ScriptsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={install.isPending}
|
||||
disabled={zipBusy}
|
||||
onClick={() =>
|
||||
install.mutate(
|
||||
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
||||
@@ -84,6 +144,23 @@ export function ScriptsPage() {
|
||||
>
|
||||
Install example
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<label className={`btn btn-outline btn-sm ${zipBusy ? "btn-disabled" : ""}`}>
|
||||
{zipBusy ? (
|
||||
<span className="loading loading-spinner loading-xs" />
|
||||
) : (
|
||||
<LuUpload className="size-4" />
|
||||
)}
|
||||
Install from zip
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
disabled={zipBusy}
|
||||
onChange={onPickZip}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||
<LuPlus className="size-4" />
|
||||
Add plugin
|
||||
@@ -341,6 +418,30 @@ export function ScriptsPage() {
|
||||
</dialog>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingZip)}
|
||||
title="Install plugin from zip?"
|
||||
message={
|
||||
pendingZip ? (
|
||||
<>
|
||||
Installing{" "}
|
||||
<span className="font-mono">{pendingZip.name}</span> 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}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||
|
||||
Reference in New Issue
Block a user