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:
2026-08-30 20:27:26 +07:00
parent a20b26b4c7
commit 88d3692116
+104 -3
View File
@@ -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 { 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 { errorMessage } from "../api/client.js";
import { import {
useDeleteScript, useDeleteScript,
useDuplicatePlugin, useDuplicatePlugin,
useForkScript, useForkScript,
useInstallPlugin, useInstallPlugin,
useMe,
useScripts, useScripts,
} from "../api/hooks.js"; } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
@@ -15,6 +16,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
import { scriptTags } from "../lib/script.js"; import { scriptTags } from "../lib/script.js";
import { useNotifications } from "../notifications.jsx"; 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() { export function ScriptsPage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,7 +46,13 @@ export function ScriptsPage() {
const fork = useForkScript(); const fork = useForkScript();
const duplicate = useDuplicatePlugin(); const duplicate = useDuplicatePlugin();
const install = useInstallPlugin(); const install = useInstallPlugin();
const me = useMe();
const isAdmin = me.data?.user?.role === "admin";
const { notify } = useNotifications(); 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 visible = useMemo(() => {
const term = query.trim().toLowerCase(); const term = query.trim().toLowerCase();
@@ -49,6 +71,44 @@ export function ScriptsPage() {
}); });
}, [scripts, query]); }, [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) { if (editName) {
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />; return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
} }
@@ -71,7 +131,7 @@ export function ScriptsPage() {
<button <button
type="button" type="button"
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm"
disabled={install.isPending} disabled={zipBusy}
onClick={() => onClick={() =>
install.mutate( install.mutate(
{ source: "example", exampleId: "get-current-time", overwrite: true }, { source: "example", exampleId: "get-current-time", overwrite: true },
@@ -84,6 +144,23 @@ export function ScriptsPage() {
> >
Install example Install example
</button> </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"> <Link to="/scripts/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" /> <LuPlus className="size-4" />
Add plugin Add plugin
@@ -341,6 +418,30 @@ export function ScriptsPage() {
</dialog> </dialog>
) : null} ) : 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 <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete}?` : ""} title={confirmDelete ? `Delete ${confirmDelete}?` : ""}