/** * @file Artifacts.tsx * @description `/artifacts` — the Artifacts hub (`/api/artifacts`, * server/lib/artifacts.js), in the same shapes as the other pages (a plain * title row, Sessions' filter bar, `.card` tiles, no decorative icons): a * search and category filter, the unfiltered front page shelved by kind * (Skills, Plugins, Pages, Files — "See all" picks the category), each * artifact a card with one primary action (Install / Installed / Open / * Preview / Download), text actions in its footer, the rest in its ⋯ menu, * and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned * workspace files (or one uploaded from the computer), skills (picked, * uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and * plugins (read from a marketplace link, or typed). One list holds the * caller's own rows and, in server mode, what every user shared; a small * **All · My library · Hub** filter narrows it. In server mode every tab is * split by who it is from, others' first (All: others' shared rows / yours; * Hub: shared by others / by you; My library: installed from others / your * own), and every section pages through 12 cards at a time. A shared skill or plugin is * installed into the viewer's own account (after a confirmation that lists * what will be written or run); one already installed there shows * **Installed** instead of the Install button; **Uninstall** (a skill's * copies moved aside, a plugin `claude plugin uninstall`) is offered only in * the card's ⋯ menu. There is no Reinstall: to take a newer version, uninstall * and install again. Editing a skill * edits its SKILL.md (name, description, instructions — a new snapshot); * its preview opens with what it installs as and when Claude uses it. Deleting a shared * row of your own only takes it out of your library — it stays in the Hub * (**Hub only**) until you remove it from there. A card's "N installs" opens * who installed it, when, and — for your own installs — the folders written. An * admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub * installs off for everyone. A text file (Markdown, code, logs) is previewed * by the dashboard itself — Markdown rendered, raw HTML in it shown as text — * while an HTML/SVG file is the server's sandboxed `raw` route in a sandboxed * iframe, so nothing a shared HTML file carries runs on the dashboard's origin. Right-clicking a * card offers the same actions as its buttons, plus copying a page's link. * Preview is offered only for what a browser shows inside that sandbox (text, * images, HTML, audio/video); an Office file, a zip or a PDF is downloaded — * the sandbox blocks both a download and the PDF viewer, so it would be blank. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MoreHorizontal, Search, X } from "lucide-react"; import { api, type Artifact, type ArtifactCandidate, type ArtifactInstalls, type ArtifactKind, type ArtifactSettings, type ServerUserUsage, } from "../lib/api"; import { usePermissions } from "../components/AuthGate"; import type { Lane } from "../lib/types"; import { eventBus } from "../lib/eventBus"; import { toast } from "../lib/toast"; import { confirmDialog } from "../lib/confirm"; import { useDialog } from "../hooks/useDialog"; import { copyText } from "../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { FileUploadPanel, PluginAddPanel, SkillAddPanel, SkillNameField, skillSlug, splitSkillMd, } from "../components/artifacts/ArtifactAddPanels"; import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview"; // The storefront's shelf order: what installs first, what is read after. const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"]; function fmtBytes(n: number | null): string { if (!n) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const fmtDate = (iso: string) => new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i; // What the sandboxed preview can show: the server's typed list minus PDF. const PREVIEWABLE = /\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i; /** What a row can do, worked out once for its card, menu and detail sheet. */ function capabilities(a: Artifact) { const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""); return { previewPath, canPreview: a.files.length > 0 && (a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))), canDownload: a.kind === "file" && a.files.length > 0, installable: a.kind === "skill" || a.kind === "plugin", // Its owner removed it from their library; it lives on in the Hub only. hubOnly: a.mine && a.shared && !a.inLibrary, }; } // The page's controls, in the shapes the other pages use (Sessions' filter bar). const segment = (on: boolean) => `rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${ on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary" }`; const smallPrimary = "btn-primary shrink-0 px-3 py-1 text-xs"; const smallSecondary = "btn-secondary shrink-0 px-2.5 py-1 text-xs"; // A text action in a card's footer. const footAction = "rounded-md px-2 py-1 text-xs font-medium text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"; export function Artifacts() { const { t } = useTranslation("artifacts"); const [settings, setSettings] = useState(null); const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all"); const { isAdmin } = usePermissions(); // Server mode: rows nobody owns — every one for an admin, those offered to you otherwise. const [unownedCount, setUnownedCount] = useState(0); const [users, setUsers] = useState([]); const [kind, setKind] = useState(""); const [q, setQ] = useState(""); const [items, setItems] = useState(null); const [adding, setAdding] = useState(null); const [addMenu, setAddMenu] = useState(false); const addMenuRef = useRef(null); const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null); // By id, so the open sheet shows the row as the latest list has it. const [detailId, setDetailId] = useState(null); useEffect(() => { api.artifacts .settings() .then(setSettings) .catch(() => setSettings(null)); }, []); const load = useCallback(() => { api.artifacts .list({ scope: tab, kind: kind || undefined, q: q.trim() || undefined }) .then((r) => setItems(r.items)) .catch((err) => { setItems([]); toast.error(err); }); }, [tab, kind, q]); useEffect(() => { const id = window.setTimeout(load, q ? 250 : 0); return () => window.clearTimeout(id); }, [load, q]); const serverMode = !!settings?.serverMode; const countUnowned = useCallback(() => { if (!serverMode) return; api.artifacts .list({ scope: "unowned" }) .then((r) => setUnownedCount(r.items.length)) .catch(() => setUnownedCount(0)); }, [serverMode]); useEffect(countUnowned, [countUnowned]); useEffect(() => { if (tab !== "unowned" || !isAdmin || users.length) return; api.users .list() .then((r) => setUsers(r.users.filter((u) => !u.disabled))) .catch(() => setUsers([])); }, [tab, isAdmin, users.length]); useEffect( () => eventBus.subscribe((msg) => { if (msg.type === "artifact_changed") { load(); countUnowned(); } }), [load, countUnowned] ); const settle = async (run: () => Promise, done: string) => { try { await run(); toast.success(t(done)); load(); countUnowned(); } catch (err) { toast.error(err); } }; // The Add menu closes on Escape or a click anywhere outside it. useEffect(() => { if (!addMenu) return; const onDown = (e: MouseEvent) => { if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setAddMenu(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [addMenu]); const server = !!settings?.serverMode; const share = async (a: Artifact) => { // A Hub-only row (its owner already removed it from their library) is // deleted by unsharing it, so that asks first. if ( a.shared && !a.inLibrary && !(await confirmDialog({ title: t("hubRemoveTitle", { title: a.title }), message: t("hubRemoveBody"), confirmLabel: t("hubRemove"), })) ) return; try { await api.artifacts.update(a.id, { shared: !a.shared }); toast.success(t(a.shared ? "unshareDone" : "shareDone")); load(); } catch (err) { toast.error(err); } }; const [renaming, setRenaming] = useState(null); const remove = async (a: Artifact) => { if ( !(await confirmDialog({ title: t("deleteTitle", { title: a.title }), message: t(!a.shared ? "deleteBody" : a.mine ? "deleteBodyKeep" : "deleteBodyShared"), confirmLabel: t("deleteConfirm"), })) ) return; try { const r = await api.artifacts.remove(a.id); if (r.kept) toast.success(t("deleteKept")); setDetailId((id) => (id === a.id ? null : id)); load(); } catch (err) { toast.error(err); } }; const install = async (a: Artifact) => { const message = a.kind === "skill" ? `${t("installSkillBody", { name: a.source })} ${a.files.map((f) => f.path).join(", ")}` : t("installPluginBody", { market: a.market, name: a.source }); if ( !(await confirmDialog({ title: t("installTitle", { title: a.title }), message, confirmLabel: t("installConfirm"), destructive: false, })) ) return; const done = (r: { installed: string }) => toast.success( a.kind === "skill" ? t("installed", { path: r.installed }) : t("installedPlugin", { name: r.installed }) ); try { done(await api.artifacts.install(a.id)); } catch (err) { const code = (err as { code?: string }).code; if (code !== "EEXISTS") return toast.error(err); if ( !(await confirmDialog({ title: t("replaceTitle", { name: a.source }), message: t("replaceBody"), confirmLabel: t("replaceConfirm"), })) ) return; try { done(await api.artifacts.install(a.id, true)); } catch (e) { toast.error(e); } } load(); }; // Taken out of the user's own accounts again; a skill's copies are moved to // ~/.claude/agenthub-replaced-skills/, never deleted. const uninstall = async (a: Artifact) => { if ( !(await confirmDialog({ title: t("uninstallTitle", { title: a.title }), message: t(a.kind === "skill" ? "uninstallSkillBody" : "uninstallPluginBody", { name: a.source, }), confirmLabel: t("uninstall"), })) ) return; try { const r = await api.artifacts.uninstall(a.id); toast.success(t("uninstalled", { name: r.uninstalled })); } catch (err) { toast.error(err); } load(); }; const actionsFor = (a: Artifact): CardActions => ({ onOpen: () => setDetailId(a.id), onShare: () => void share(a), onRename: () => { setDetailId(null); setRenaming(a); }, onDelete: () => void remove(a), onInstall: () => void install(a), onUninstall: () => void uninstall(a), onPreview: (path) => { setDetailId(null); setPreview({ item: a, path }); }, }); const card = (a: Artifact) => tab === "unowned" ? (
{(a.suggestedForMe || isAdmin) && ( )} {a.suggestedForMe && ( )} {isAdmin && users.length > 0 && ( )}
) : ( ); // Local mode (everything is yours): the unfiltered page is shelved by // kind; a category or a search is one flat grid of results. const shelves = items && !kind && !q.trim() ? SHELVES.map((k) => ({ kind: k, rows: items.filter((a) => a.kind === k) })).filter( (s) => s.rows.length > 0 ) : null; // In server mode every tab is split by who it is from, others' first: All // into others' shared rows and yours, the Hub into what others shared and // what you shared, My library into what you installed from others (the // server adds those to `mine`) and your own. const halves = server && items && tab !== "unowned" ? (["others", "own"] as const).map((who) => ({ key: `${tab}.${who}`, rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)), })) : null; const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null); return (

{t("title")}

{t(server ? "subtitleServer" : "subtitle")}

{addMenu && (
{SHELVES.map((k) => ( ))}
)}
setQ(e.target.value)} placeholder={t("search")} aria-label={t("search")} className="input w-full pl-10 pr-9" /> {q && ( )}
{(["", ...SHELVES] as const).map((k) => ( ))}
{server && (
{(["all", "mine", "hub"] as const).map((v) => ( ))} {(isAdmin || unownedCount > 0 || tab === "unowned") && ( )}
)}
{items === null ? ( ) : items.length === 0 && tab === "unowned" ? (

{t("unowned.empty")}

) : items.length === 0 ? ( setAdding(k)} /> ) : halves ? (
{halves.map((h) => (

{t(`section.${h.key}`)} {h.rows.length}

{t(`section.${h.key}Hint`)}

{h.rows.length ? ( ) : (

{t("section.empty")}

)}
))}
) : shelves ? (
{shelves.map((s) => (

{t(`kind.${s.kind}`)} {s.rows.length}

{t(`blurb.${s.kind}`)}

))}
) : (

{t("results", { count: items.length })}

)} {detail && ( setDetailId(null)} {...actionsFor(detail)} /> )} {adding && setAdding(null)} onAdded={load} />} {preview && setPreview(null)} />} {renaming && ( setRenaming(null)} onSaved={() => { setRenaming(null); load(); }} /> )}
); } // Cards per page of a section: four rows of the three-column grid. const PAGE_SIZE = 12; /** A section's cards, a page at a time once there are more than `PAGE_SIZE` * (the Sessions table's Previous · n / m · Next). Keyed by the caller on the * filters, so a new tab, kind or search starts again at page one. */ function PagedGrid({ rows, card }: { rows: Artifact[]; card: (a: Artifact) => React.ReactNode }) { const { t } = useTranslation("artifacts"); const [page, setPage] = useState(0); const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); const at = Math.min(page, pages - 1); const pager = "rounded-md bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"; return ( <>
    {rows.slice(at * PAGE_SIZE, (at + 1) * PAGE_SIZE).map(card)}
{pages > 1 && ( )} ); } function SkeletonGrid({ label }: { label: string }) { return (
{label}
    {Array.from({ length: 6 }, (_, i) => (
  • ))}
); } function EmptyState({ hub, searching, onAdd, }: { hub: boolean; searching: boolean; onAdd: (k: ArtifactKind) => void; }) { const { t } = useTranslation("artifacts"); return (

{t(searching ? "empty.searchTitle" : hub ? "empty.hubTitle" : "empty.title")}

{searching ? t("empty.search") : t(hub ? "empty.hub" : "empty.mine")}

{!hub && !searching && (
{SHELVES.map((k) => ( ))}
)}
); } type CardActions = { onOpen: () => void; onShare: () => void; onRename: () => void; onDelete: () => void; onInstall: () => void; onUninstall: () => void; onPreview: (path: string) => void; }; /** The one primary action on a card / detail sheet. */ function PrimaryAction({ item: a, canInstall, onInstall, onPreview, }: { item: Artifact; canInstall: boolean; onInstall: () => void; onPreview: (path: string) => void; }) { const { t } = useTranslation("artifacts"); const c = capabilities(a); if (a.installed) return ( {t("installedBadge")} ); if (c.installable) return ( ); if (a.kind === "page" && a.url) return ( {t("open")} ); if (c.canPreview) return ( ); if (c.canDownload) return ( {t("download")} ); return null; } function ArtifactCard({ item: a, server, canInstall, onOpen, onShare, onRename, onDelete, onInstall, onUninstall, onPreview, }: CardActions & { item: Artifact; server: boolean; canInstall: boolean; }) { const { t } = useTranslation("artifacts"); const menu = useContextMenu(); const pick = (fn: () => void) => () => { menu.close(); fn(); }; const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a); // The primary action already offers Preview/Download for a plain file. const primaryIsPreview = !installable && a.kind !== "page" && canPreview; return (
  • menu.open(e, null)} >
    {a.shared && ( {t(hubOnly ? "hubOnly" : "shared")} )} {a.auto && ( {t("auto")} )} {a.description && (

    {a.description}

    )}
    {server && a.owner && !a.mine && ( {t("by", { name: a.owner })} · )} {fmtDate(a.createdAt)} {a.installs > 0 && ( <> · )} {canPreview && !primaryIsPreview && ( )} {canDownload && primaryIsPreview && ( {t("download")} )} {server && a.canChange && ( )} {a.canChange && ( )}
    {menu.menu && ( {t("details")} {a.kind === "page" && a.url && ( <> window.open(a.url!, "_blank", "noopener,noreferrer"))}> {t("open")} void copyText(a.url!))}>{t("copyLink")} )} {canPreview && ( onPreview(previewPath))}>{t("preview")} )} {canDownload && ( { window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true); })} > {t("download")} )} {installable && !a.installed && ( {t("install")} )} {a.installed && ( {t("uninstall")} )} {a.canChange && ( <> {server && ( {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} )} {t("rename")} {!hubOnly && ( {t("delete")} )} )} )}
  • ); } /** An artifact's own page: title and main action up top, a strip of facts, * what it is, how it installs, and what it carries. */ function DetailDialog({ item: a, server, canInstall, onClose, onShare, onRename, onDelete, onInstall, onPreview, }: CardActions & { item: Artifact; server: boolean; canInstall: boolean; onClose: () => void; }) { const { t } = useTranslation("artifacts"); const { canPreview, canDownload, hubOnly } = capabilities(a); const secondary = smallSecondary; const stats: { label: string; value: React.ReactNode }[] = [ { label: t("stat.kind"), value: t(`kindOne.${a.kind}`) }, ...(a.kind === "skill" || a.kind === "plugin" ? [{ label: t("stat.installs"), value: a.installs > 0 ? : "0" }] : []), ...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []), ...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []), { label: t("stat.updated"), value: fmtDate(a.updatedAt) }, ]; return (

    {a.title}

    {server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)} {a.source ? ` · ${a.source}` : ""}

    {a.shared && ( {t(hubOnly ? "hubOnly" : "shared")} )}
    {canPreview && a.kind !== "file" && ( )} {canDownload && canPreview && ( {t("download")} )} {a.kind === "page" && a.url && ( )}
    {stats.map((s) => (
    {s.label}
    {s.value}
    ))}

    {t(a.kind === "skill" ? "preview.whenUsed" : "about")}

    {a.description || {t("preview.noDescription")}}

    {a.kind === "skill" && a.source && (

    {t("detail.runAs")} /{a.source}

    {t("preview.installsAs", { slug: a.source })}

    )} {a.kind === "plugin" && (

    {t("form.pluginKey")}: {a.source}

    {a.market && (

    {t("detail.market")}: {a.market}

    )}
    )} {a.files.length > 0 && a.kind !== "page" && (

    {t("files", { count: a.files.length })}

      {a.files.map((f) => (
    • {f.path} {fmtBytes(f.size)} {PREVIEWABLE.test(f.path) && ( )} {t("download")}
    • ))}
    )}

    {t("detail.added", { date: new Date(a.createdAt).toLocaleString() })}

    {a.canChange && (
    {server && ( )} {!hubOnly && ( )}
    )}
    ); } /** "N installs" on a card; a click lists who installed it, when, and where. */ function InstallsButton({ item }: { item: Artifact }) { const { t } = useTranslation("artifacts"); const [open, setOpen] = useState(false); const [data, setData] = useState(null); const box = useRef(null); useEffect(() => { if (!open) return; setData(null); api.artifacts .installs(item.id) .then(setData) .catch((err) => { setOpen(false); toast.error(err); }); const onDown = (e: MouseEvent) => { if (!box.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open, item.id]); return ( {open && (

    {t("installsTitle")}

    {data === null ? (

    {t("loading")}

    ) : (
      {data.items.map((i, n) => (
    • {i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))} {i.replaced && ( · {t("installsReplaced")} )} {new Date(i.at).toLocaleString()}
      {i.paths && (
      {i.paths.length ? ( i.paths.map((p) => (

      {p}

      )) ) : (

      {t("installsPluginWhere")}

      )}
      )}
    • ))} {data.unrecorded > 0 && (
    • {t("installsUnrecorded", { count: data.unrecorded })}
    • )} {data.items.length === 0 && data.unrecorded === 0 && (
    • {t("installsNoneOfYours")}
    • )}
    )}
    )}
    ); } function Modal({ title, onClose, children, wide = false, size, flush = false, }: { title: string; onClose: () => void; children: React.ReactNode; wide?: boolean; /** A detail sheet: wider than a form, narrower than a preview. */ size?: "md"; /** The body draws its own padding (a preview fills the panel edge to edge). */ flush?: boolean; }) { const { t } = useTranslation("artifacts"); const panel = useRef(null); useDialog(true, panel, onClose); return (
    e.stopPropagation()} className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : size === "md" ? "max-w-2xl" : "max-w-lg"} flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50`} >

    {title}

    {children}
    ); } const fieldLabel = "mb-1 block text-xs text-fg-secondary"; function RenameDialog({ item, onClose, onSaved, }: { item: Artifact; onClose: () => void; onSaved: () => void; }) { const { t } = useTranslation("artifacts"); const plugin = item.kind === "plugin"; // A skill edits its SKILL.md too: name, description and instructions, // read from the snapshot and saved as a new one. const skill = item.kind === "skill" && item.files.some((f) => f.path === "SKILL.md"); const [title, setTitle] = useState(item.title); const [description, setDescription] = useState(item.description); const [source, setSource] = useState(item.source ?? ""); const [market, setMarket] = useState(item.market ?? ""); const [orig, setOrig] = useState<{ name: string; description: string; body: string } | null>( null ); const [skillName, setSkillName] = useState(""); const [content, setContent] = useState(""); const [loadFailed, setLoadFailed] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { if (!skill) return; let live = true; fetch(api.artifacts.rawUrl(item.id, "SKILL.md"), { credentials: "same-origin" }) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((text) => { if (!live) return; const parts = splitSkillMd(text); const o = { ...parts, name: parts.name || item.source || "" }; setOrig(o); setSkillName(o.name); setDescription(o.description || item.description); setContent(o.body); }) .catch(() => live && setLoadFailed(true)); return () => { live = false; }; }, [skill, item.id, item.source, item.description]); const skillChanged = !!orig && (skillName.trim() !== orig.name || description.trim() !== orig.description || content !== orig.body); const blocked = busy || !title.trim() || (plugin && (!source.trim() || !market.trim())) || (skill && (!orig || !skillSlug(skillName) || !description.trim() || !content.trim())); return (
    { e.preventDefault(); setBusy(true); try { await api.artifacts.update(item.id, { title, ...(skill ? skillChanged ? { skill: { name: skillName.trim(), description: description.trim(), content, }, } : {} : { description }), ...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}), ...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}), }); onSaved(); } catch (err) { toast.error(err); } finally { setBusy(false); } }} > {skill ? ( loadFailed ? (

    {t("previewFailed")}

    ) : !orig ? (

    {t("loading")}

    ) : ( <>