feat(scripts): add sidecar icons and tags in the admin UI
Serve optional PNG/JPG next to each script and show meta tags in lists, cards, and the editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -55,11 +55,42 @@ export function writeScript(name, content) {
|
|||||||
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
||||||
|
* @returns {{ filePath: string, contentType: string } | null}
|
||||||
|
*/
|
||||||
|
export function resolveScriptIcon(name) {
|
||||||
|
assertScriptName(name);
|
||||||
|
const base = name.slice(0, -3);
|
||||||
|
for (const { ext, contentType } of [
|
||||||
|
{ ext: "png", contentType: "image/png" },
|
||||||
|
{ ext: "jpg", contentType: "image/jpeg" },
|
||||||
|
]) {
|
||||||
|
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||||
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
|
return { filePath, contentType };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scriptHasIcon(name) {
|
||||||
|
return resolveScriptIcon(name) != null;
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteScript(name) {
|
export function deleteScript(name) {
|
||||||
assertScriptName(name);
|
assertScriptName(name);
|
||||||
const filePath = path.join(SCRIPTS_DIR, name);
|
const filePath = path.join(SCRIPTS_DIR, name);
|
||||||
if (!fs.existsSync(filePath)) return false;
|
if (!fs.existsSync(filePath)) return false;
|
||||||
|
const icon = resolveScriptIcon(name);
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
|
if (icon) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(icon.filePath);
|
||||||
|
} catch {
|
||||||
|
// ignore missing icon
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ async function fetchHtml(ctx) {
|
|||||||
fetchHtml.meta = {
|
fetchHtml.meta = {
|
||||||
description: "Fetch HTML and optionally select elements or transform with JSONata",
|
description: "Fetch HTML and optionally select elements or transform with JSONata",
|
||||||
previewConfigKey: "url",
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP"],
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "Page URL" },
|
url: { type: "string", required: true, description: "Page URL" },
|
||||||
selector: { type: "string", required: false, description: "CSS selector" },
|
selector: { type: "string", required: false, description: "CSS selector" },
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async function fetchHttp(ctx) {
|
|||||||
fetchHttp.meta = {
|
fetchHttp.meta = {
|
||||||
description: "Fetch a URL and store the response body.",
|
description: "Fetch a URL and store the response body.",
|
||||||
previewConfigKey: "url",
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP"],
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "Request URL" },
|
url: { type: "string", required: true, description: "Request URL" },
|
||||||
method: {
|
method: {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ async function fetchRssFeed(ctx) {
|
|||||||
fetchRssFeed.meta = {
|
fetchRssFeed.meta = {
|
||||||
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
||||||
previewConfigKey: "url",
|
previewConfigKey: "url",
|
||||||
|
tags: ["RSS"],
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
|
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
|
||||||
outputVar: {
|
outputVar: {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ triggerWorkflow.meta = {
|
|||||||
description:
|
description:
|
||||||
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
|
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
|
||||||
previewConfigKey: "name",
|
previewConfigKey: "name",
|
||||||
|
tags: ["trigger"],
|
||||||
config: {
|
config: {
|
||||||
name: {
|
name: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "fs";
|
||||||
import {
|
import {
|
||||||
clearScriptCache,
|
clearScriptCache,
|
||||||
inspectScriptSource,
|
inspectScriptSource,
|
||||||
@@ -21,11 +22,27 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
content == null
|
content == null
|
||||||
? { meta: null, metaError: "script not found" }
|
? { meta: null, metaError: "script not found" }
|
||||||
: inspectScriptSource(name, content);
|
: inspectScriptSource(name, content);
|
||||||
return { name, ...inspected };
|
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
|
||||||
});
|
});
|
||||||
return { scripts };
|
return { scripts };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get("/scripts/:name/icon", async (req, reply) => {
|
||||||
|
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||||
|
try {
|
||||||
|
fsStore.assertScriptName(name);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||||
|
}
|
||||||
|
const icon = fsStore.resolveScriptIcon(name);
|
||||||
|
if (icon == null) return reply.code(404).send({ error: "icon not found" });
|
||||||
|
const body = fs.readFileSync(icon.filePath);
|
||||||
|
return reply
|
||||||
|
.type(icon.contentType)
|
||||||
|
.header("Cache-Control", "private, max-age=60")
|
||||||
|
.send(body);
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get("/scripts/:name", async (req, reply) => {
|
fastify.get("/scripts/:name", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +52,7 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
const content = fsStore.readScript(name);
|
const content = fsStore.readScript(name);
|
||||||
if (content == null) return reply.code(404).send({ error: "script not found" });
|
if (content == null) return reply.code(404).send({ error: "script not found" });
|
||||||
return { name, content, ...inspectScriptSource(name, content) };
|
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.put("/scripts/:name", async (req, reply) => {
|
fastify.put("/scripts/:name", async (req, reply) => {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { TbFileTypeJs } from "react-icons/tb";
|
||||||
|
|
||||||
|
export function scriptIconSrc(name) {
|
||||||
|
return `/api/scripts/${encodeURIComponent(name)}/icon`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-name PNG/JPG next to the script, or a JS document icon. */
|
||||||
|
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(false);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const showFile = Boolean(name) && hasIcon !== false && !failed;
|
||||||
|
|
||||||
|
if (!showFile) {
|
||||||
|
return <TbFileTypeJs className={`${className} text-warning`} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={scriptIconSrc(name)}
|
||||||
|
alt=""
|
||||||
|
className={`${className} object-contain`}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { scriptTags } from "../lib/script.js";
|
||||||
|
import { TagBadge } from "./TagBadge.jsx";
|
||||||
|
|
||||||
function FieldTable({ title, fields }) {
|
function FieldTable({ title, fields }) {
|
||||||
const entries = Object.entries(fields ?? {});
|
const entries = Object.entries(fields ?? {});
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
@@ -56,9 +59,18 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = scriptTags(meta);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
{meta.description ? <p className="text-sm">{meta.description}</p> : null}
|
{meta.description ? <p className="text-sm">{meta.description}</p> : null}
|
||||||
|
{tags.length ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagBadge key={tag} tag={tag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<FieldTable title="Config" fields={meta.config} />
|
<FieldTable title="Config" fields={meta.config} />
|
||||||
<FieldTable title="Input (data)" fields={meta.input} />
|
<FieldTable title="Input (data)" fields={meta.input} />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { tagColor } from "../lib/script.js";
|
||||||
|
|
||||||
|
export function TagBadge({ tag, className = "", onClick, title }) {
|
||||||
|
const { bg, text } = tagColor(tag);
|
||||||
|
const Comp = onClick ? "button" : "span";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
type={onClick ? "button" : undefined}
|
||||||
|
className={`badge badge-sm border-0 font-medium ${className}`}
|
||||||
|
style={{ backgroundColor: bg, color: text }}
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useScripts } from "../../api/hooks.js";
|
import { useScripts } from "../../api/hooks.js";
|
||||||
|
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||||
|
import { scriptTags } from "../../lib/script.js";
|
||||||
|
|
||||||
export function AddScriptDialog({ open, onClose, onPick }) {
|
export function AddScriptDialog({ open, onClose, onPick }) {
|
||||||
const { data: scripts = [], isLoading } = useScripts();
|
const { data: scripts = [], isLoading } = useScripts();
|
||||||
@@ -12,12 +14,15 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
|||||||
description: typeof s === "string" ? "" : s.meta?.description ?? "",
|
description: typeof s === "string" ? "" : s.meta?.description ?? "",
|
||||||
meta: typeof s === "string" ? null : s.meta,
|
meta: typeof s === "string" ? null : s.meta,
|
||||||
metaError: typeof s === "string" ? null : s.metaError,
|
metaError: typeof s === "string" ? null : s.metaError,
|
||||||
|
hasIcon: typeof s === "string" ? undefined : s.hasIcon,
|
||||||
|
tags: typeof s === "string" ? [] : scriptTags(s.meta),
|
||||||
}));
|
}));
|
||||||
if (!term) return list;
|
if (!term) return list;
|
||||||
return list.filter(
|
return list.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.name.toLowerCase().includes(term) ||
|
s.name.toLowerCase().includes(term) ||
|
||||||
s.description.toLowerCase().includes(term),
|
s.description.toLowerCase().includes(term) ||
|
||||||
|
s.tags.some((t) => t.toLowerCase().includes(term)),
|
||||||
);
|
);
|
||||||
}, [scripts, q]);
|
}, [scripts, q]);
|
||||||
|
|
||||||
@@ -61,9 +66,12 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
|||||||
setQ("");
|
setQ("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono">{s.name}</span>
|
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
|
||||||
<span className="text-xs opacity-70 line-clamp-2">
|
<span className="min-w-0">
|
||||||
{s.metaError ? s.metaError : s.description || "No description"}
|
<span className="font-mono">{s.name}</span>
|
||||||
|
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||||
|
{s.metaError ? s.metaError : s.description || "No description"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { StatusBadge } from "../../lib/format.jsx";
|
|||||||
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||||
import { ConfigFields } from "./ConfigFields.jsx";
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||||
|
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||||
|
|
||||||
export function ScriptCard({
|
export function ScriptCard({
|
||||||
step,
|
step,
|
||||||
@@ -66,6 +67,9 @@ export function ScriptCard({
|
|||||||
>
|
>
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
{step.kind === "script" && step.script ? (
|
||||||
|
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 flex-1 text-left"
|
className="min-w-0 flex-1 text-left"
|
||||||
@@ -76,7 +80,7 @@ export function ScriptCard({
|
|||||||
<span className="min-w-0 truncate">{baseName}</span>
|
<span className="min-w-0 truncate">{baseName}</span>
|
||||||
{!expanded && preview ? (
|
{!expanded && preview ? (
|
||||||
<span
|
<span
|
||||||
className="badge badge-secondary badge-sm max-w-[min(16rem,45%)] shrink-0 truncate font-mono font-normal"
|
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
||||||
title={previewFull}
|
title={previewFull}
|
||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
|
|||||||
main.meta = {
|
main.meta = {
|
||||||
description: "",
|
description: "",
|
||||||
previewConfigKey: "",
|
previewConfigKey: "",
|
||||||
|
tags: [],
|
||||||
config: {},
|
config: {},
|
||||||
input: {},
|
input: {},
|
||||||
output: {},
|
output: {},
|
||||||
@@ -29,6 +30,38 @@ export function normalizeScriptName(name) {
|
|||||||
return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`;
|
return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scriptTags(meta) {
|
||||||
|
if (!Array.isArray(meta?.tags)) return [];
|
||||||
|
return meta.tags.map((t) => String(t).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_PALETTE = [
|
||||||
|
{ bg: "color-mix(in oklab, #2563eb 22%, transparent)", text: "#2563eb" },
|
||||||
|
{ bg: "color-mix(in oklab, #059669 22%, transparent)", text: "#059669" },
|
||||||
|
{ bg: "color-mix(in oklab, #d97706 22%, transparent)", text: "#b45309" },
|
||||||
|
{ bg: "color-mix(in oklab, #db2777 22%, transparent)", text: "#db2777" },
|
||||||
|
{ bg: "color-mix(in oklab, #7c3aed 22%, transparent)", text: "#7c3aed" },
|
||||||
|
{ bg: "color-mix(in oklab, #0d9488 22%, transparent)", text: "#0f766e" },
|
||||||
|
{ bg: "color-mix(in oklab, #ea580c 22%, transparent)", text: "#c2410c" },
|
||||||
|
{ bg: "color-mix(in oklab, #4f46e5 22%, transparent)", text: "#4338ca" },
|
||||||
|
{ bg: "color-mix(in oklab, #dc2626 22%, transparent)", text: "#b91c1c" },
|
||||||
|
{ bg: "color-mix(in oklab, #65a30d 22%, transparent)", text: "#4d7c0f" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashTag(tag) {
|
||||||
|
let h = 2166136261;
|
||||||
|
const s = String(tag).toLowerCase();
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h ^= s.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagColor(tag) {
|
||||||
|
return TAG_PALETTE[hashTag(tag) % TAG_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
export function prettyJson(value) {
|
export function prettyJson(value) {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||||
import { LogViewer } from "../components/LogViewer.jsx";
|
import { LogViewer } from "../components/LogViewer.jsx";
|
||||||
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||||
import { StatusBadge } from "../lib/format.jsx";
|
import { StatusBadge } from "../lib/format.jsx";
|
||||||
import {
|
import {
|
||||||
@@ -133,6 +134,7 @@ export function ScriptDryRunPage() {
|
|||||||
<Link to={backHref} className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
<Link to={backHref} className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||||
<LuArrowLeft className="size-4" />
|
<LuArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
||||||
<h1 className="truncate font-mono text-lg font-semibold">{name}</h1>
|
<h1 className="truncate font-mono text-lg font-semibold">{name}</h1>
|
||||||
<span className="badge badge-ghost badge-sm">dry run</span>
|
<span className="badge badge-ghost badge-sm">dry run</span>
|
||||||
{runStatus ? <StatusBadge status={runStatus} /> : null}
|
{runStatus ? <StatusBadge status={runStatus} /> : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
|
|||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import { useSaveScript, useScript } from "../api/hooks.js";
|
import { useSaveScript, useScript } from "../api/hooks.js";
|
||||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||||
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||||
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ export function ScriptEditPage() {
|
|||||||
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||||
<LuArrowLeft className="size-4" />
|
<LuArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
||||||
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
|
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
|
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { LuPencil, LuPlay, LuPlus, LuTrash2 } from "react-icons/lu";
|
import { LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import { useDeleteScript, useScripts } from "../api/hooks.js";
|
import { useDeleteScript, useScripts } from "../api/hooks.js";
|
||||||
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
|
import { TagBadge } from "../components/TagBadge.jsx";
|
||||||
|
import { scriptTags } from "../lib/script.js";
|
||||||
|
|
||||||
export function ScriptsPage() {
|
export function ScriptsPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -10,61 +13,108 @@ export function ScriptsPage() {
|
|||||||
const editName = params.get("edit");
|
const editName = params.get("edit");
|
||||||
const { data: scripts = [], isLoading } = useScripts();
|
const { data: scripts = [], isLoading } = useScripts();
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const del = useDeleteScript();
|
const del = useDeleteScript();
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const term = query.trim().toLowerCase();
|
||||||
|
if (!term) return scripts;
|
||||||
|
return scripts.filter((s) => {
|
||||||
|
const name = typeof s === "string" ? s : s.name ?? "";
|
||||||
|
const description = typeof s === "string" ? "" : s.meta?.description ?? "";
|
||||||
|
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||||
|
return (
|
||||||
|
name.toLowerCase().includes(term) ||
|
||||||
|
description.toLowerCase().includes(term) ||
|
||||||
|
tags.some((t) => t.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [scripts, query]);
|
||||||
|
|
||||||
if (editName) {
|
if (editName) {
|
||||||
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-xl font-semibold">Scripts</h1>
|
<h1 className="text-xl font-semibold">Scripts</h1>
|
||||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
<div className="flex items-center gap-2">
|
||||||
<LuPlus className="size-4" />
|
<label className="input input-sm w-48 sm:w-64">
|
||||||
Add
|
<LuSearch className="size-4 opacity-60" />
|
||||||
</Link>
|
<input
|
||||||
|
type="search"
|
||||||
|
className="grow"
|
||||||
|
placeholder="Search scripts"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner" />
|
<span className="loading loading-spinner" />
|
||||||
|
) : scripts.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-50">No scripts yet.</p>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-50">No scripts match “{query.trim()}”.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="flex flex-wrap gap-3">
|
||||||
<table className="table table-sm">
|
{visible.map((s) => {
|
||||||
<thead>
|
const name = typeof s === "string" ? s : s.name;
|
||||||
<tr>
|
const description = typeof s === "string" ? "" : s.meta?.description;
|
||||||
<th>Name</th>
|
const metaError = typeof s === "string" ? null : s.metaError;
|
||||||
<th>Description</th>
|
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
|
||||||
<th className="w-28" />
|
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||||
</tr>
|
return (
|
||||||
</thead>
|
<article
|
||||||
<tbody>
|
key={name}
|
||||||
{scripts.map((s) => {
|
className="card bg-base-100 border border-base-300 w-60"
|
||||||
const name = typeof s === "string" ? s : s.name;
|
>
|
||||||
const description = typeof s === "string" ? "" : s.meta?.description;
|
<div className="card-body p-4 gap-2">
|
||||||
const metaError = typeof s === "string" ? null : s.metaError;
|
<Link
|
||||||
return (
|
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||||
<tr key={name} className="hover">
|
className="flex flex-col items-center gap-2 min-h-0"
|
||||||
<td className="font-mono">
|
>
|
||||||
<Link className="link" to={`/scripts/${encodeURIComponent(name)}/edit`}>
|
<div className="flex h-20 w-full items-center justify-center rounded-box bg-base-200">
|
||||||
{name}
|
<ScriptIcon name={name} hasIcon={hasIcon} className="size-14" />
|
||||||
</Link>
|
</div>
|
||||||
</td>
|
<h2 className="card-title font-mono text-sm justify-center text-center w-full">
|
||||||
<td className="max-w-xl truncate text-sm opacity-80">
|
<span className="truncate" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs opacity-80 line-clamp-2 min-h-8">
|
||||||
{metaError ? (
|
{metaError ? (
|
||||||
<span className="text-error">{metaError}</span>
|
<span className="text-error">{metaError}</span>
|
||||||
) : (
|
) : (
|
||||||
description || "—"
|
description || "—"
|
||||||
)}
|
)}
|
||||||
</td>
|
</p>
|
||||||
<td className="text-right whitespace-nowrap">
|
{tags.length ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagBadge
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
title={`Search ${tag}`}
|
||||||
|
onClick={() => setQuery(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="card-actions justify-end mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
title="Dry run"
|
title="Dry run"
|
||||||
onClick={() =>
|
onClick={() => navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)}
|
||||||
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -83,12 +133,11 @@ export function ScriptsPage() {
|
|||||||
>
|
>
|
||||||
<LuTrash2 className="size-4" />
|
<LuTrash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
</article>
|
||||||
})}
|
);
|
||||||
</tbody>
|
})}
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user