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:
2026-08-15 21:21:24 +07:00
co-authored by Cursor
parent bccbf551e3
commit cae308359a
15 changed files with 256 additions and 47 deletions
@@ -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 }) {
const entries = Object.entries(fields ?? {});
if (entries.length === 0) return null;
@@ -56,9 +59,18 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) {
);
}
const tags = scriptTags(meta);
return (
<div className={`space-y-3 ${className}`}>
{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">
<FieldTable title="Config" fields={meta.config} />
<FieldTable title="Input (data)" fields={meta.input} />
+17
View File
@@ -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 { useScripts } from "../../api/hooks.js";
import { ScriptIcon } from "../ScriptIcon.jsx";
import { scriptTags } from "../../lib/script.js";
export function AddScriptDialog({ open, onClose, onPick }) {
const { data: scripts = [], isLoading } = useScripts();
@@ -12,12 +14,15 @@ export function AddScriptDialog({ open, onClose, onPick }) {
description: typeof s === "string" ? "" : s.meta?.description ?? "",
meta: typeof s === "string" ? null : s.meta,
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;
return list.filter(
(s) =>
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]);
@@ -61,9 +66,12 @@ export function AddScriptDialog({ open, onClose, onPick }) {
setQ("");
}}
>
<span className="font-mono">{s.name}</span>
<span className="text-xs opacity-70 line-clamp-2">
{s.metaError ? s.metaError : s.description || "No description"}
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
<span className="min-w-0">
<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>
</button>
</li>
@@ -10,6 +10,7 @@ import { StatusBadge } from "../../lib/format.jsx";
import { contextFromMeta, prettyJson } from "../../lib/script.js";
import { ConfigFields } from "./ConfigFields.jsx";
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
import { ScriptIcon } from "../ScriptIcon.jsx";
export function ScriptCard({
step,
@@ -66,6 +67,9 @@ export function ScriptCard({
>
<LuGripVertical className="size-4 opacity-60" />
</button>
{step.kind === "script" && step.script ? (
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
) : null}
<button
type="button"
className="min-w-0 flex-1 text-left"
@@ -76,7 +80,7 @@ export function ScriptCard({
<span className="min-w-0 truncate">{baseName}</span>
{!expanded && preview ? (
<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}
>
{preview}
+33
View File
@@ -5,6 +5,7 @@ export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
main.meta = {
description: "",
previewConfigKey: "",
tags: [],
config: {},
input: {},
output: {},
@@ -29,6 +30,38 @@ export function normalizeScriptName(name) {
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) {
if (value === null || value === undefined) return "";
try {
@@ -10,6 +10,7 @@ import {
} from "../api/hooks.js";
import { CodeEditor } from "../components/CodeEditor.jsx";
import { LogViewer } from "../components/LogViewer.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
import { StatusBadge } from "../lib/format.jsx";
import {
@@ -133,6 +134,7 @@ export function ScriptDryRunPage() {
<Link to={backHref} className="btn btn-ghost btn-sm btn-square" aria-label="Back">
<LuArrowLeft className="size-4" />
</Link>
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
<h1 className="truncate font-mono text-lg font-semibold">{name}</h1>
<span className="badge badge-ghost badge-sm">dry run</span>
{runStatus ? <StatusBadge status={runStatus} /> : null}
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useSaveScript, useScript } from "../api/hooks.js";
import { CodeEditor } from "../components/CodeEditor.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
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">
<LuArrowLeft className="size-4" />
</Link>
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
<div className="flex-1" />
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
+89 -40
View File
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useMemo, useState } from "react";
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 { 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() {
const [params] = useSearchParams();
@@ -10,61 +13,108 @@ export function ScriptsPage() {
const editName = params.get("edit");
const { data: scripts = [], isLoading } = useScripts();
const [confirmDelete, setConfirmDelete] = useState(null);
const [query, setQuery] = useState("");
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) {
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
}
return (
<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>
<Link to="/scripts/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" />
Add
</Link>
<div className="flex items-center gap-2">
<label className="input input-sm w-48 sm:w-64">
<LuSearch className="size-4 opacity-60" />
<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>
{isLoading ? (
<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">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th className="w-28" />
</tr>
</thead>
<tbody>
{scripts.map((s) => {
const name = typeof s === "string" ? s : s.name;
const description = typeof s === "string" ? "" : s.meta?.description;
const metaError = typeof s === "string" ? null : s.metaError;
return (
<tr key={name} className="hover">
<td className="font-mono">
<Link className="link" to={`/scripts/${encodeURIComponent(name)}/edit`}>
{name}
</Link>
</td>
<td className="max-w-xl truncate text-sm opacity-80">
<div className="flex flex-wrap gap-3">
{visible.map((s) => {
const name = typeof s === "string" ? s : s.name;
const description = typeof s === "string" ? "" : s.meta?.description;
const metaError = typeof s === "string" ? null : s.metaError;
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
return (
<article
key={name}
className="card bg-base-100 border border-base-300 w-60"
>
<div className="card-body p-4 gap-2">
<Link
to={`/scripts/${encodeURIComponent(name)}/edit`}
className="flex flex-col items-center gap-2 min-h-0"
>
<div className="flex h-20 w-full items-center justify-center rounded-box bg-base-200">
<ScriptIcon name={name} hasIcon={hasIcon} className="size-14" />
</div>
<h2 className="card-title font-mono text-sm justify-center text-center w-full">
<span className="truncate" title={name}>
{name}
</span>
</h2>
</Link>
<p className="text-xs opacity-80 line-clamp-2 min-h-8">
{metaError ? (
<span className="text-error">{metaError}</span>
) : (
description || "—"
)}
</td>
<td className="text-right whitespace-nowrap">
</p>
{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
type="button"
className="btn btn-ghost btn-xs"
title="Dry run"
onClick={() =>
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
}
onClick={() => navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)}
>
<LuPlay className="size-4" />
</button>
@@ -83,12 +133,11 @@ export function ScriptsPage() {
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</article>
);
})}
</div>
)}