/** * @file Artifacts.tsx * @description `/artifacts` — the Artifacts hub (`/api/artifacts`, * server/lib/artifacts.js): 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). **My library** lists the caller's * own; in server mode the **Hub** tab lists what every user shared, and a * shared skill or plugin is installed into the viewer's own account from * there (after a confirmation that lists what will be written or run). An * admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub * installs off for everyone. Previews of a file are * 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 { Download, ExternalLink, Eye, FileText, Globe, Package, Pencil, Plus, Share2, Sparkles, Trash2, X, } from "lucide-react"; import { api, type Artifact, type ArtifactCandidate, type ArtifactKind, type ArtifactSettings, } from "../lib/api"; 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, } from "../components/artifacts/ArtifactAddPanels"; const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const; const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"]; 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 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; export function Artifacts() { const { t } = useTranslation("artifacts"); const [settings, setSettings] = useState(null); const [tab, setTab] = useState<"mine" | "hub">("mine"); 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); 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]); useEffect( () => eventBus.subscribe((msg) => { if (msg.type === "artifact_changed") load(); }), [load] ); // 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) => { 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("deleteBody"), confirmLabel: t("deleteConfirm"), })) ) return; try { await api.artifacts.remove(a.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(); }; return (

{t("title")}

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

{addMenu && (
{KINDS.map((k) => { const Icon = KIND_ICON[k]; return ( ); })}
)}
{server && (
{(["mine", "hub"] as const).map((v) => ( ))}
)}
{(["", ...KINDS] as const).map((k) => ( ))}
setQ(e.target.value)} placeholder={t("search")} aria-label={t("search")} className="ml-auto w-56 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary" />
{items === null ? (

{t("loading")}

) : items.length === 0 ? (

{t(tab === "hub" ? "empty.hub" : "empty.mine")}

) : (
    {items.map((a) => ( void share(a)} onRename={() => setRenaming(a)} onDelete={() => void remove(a)} onInstall={() => void install(a)} onPreview={(path) => setPreview({ item: a, path })} /> ))}
)} {adding && setAdding(null)} onAdded={load} />} {preview && setPreview(null)} />} {renaming && ( setRenaming(null)} onSaved={() => { setRenaming(null); load(); }} /> )}
); } function ArtifactCard({ item: a, server, canInstall, onShare, onRename, onDelete, onInstall, onPreview, }: { item: Artifact; server: boolean; canInstall: boolean; onShare: () => void; onRename: () => void; onDelete: () => void; onInstall: () => void; onPreview: (path: string) => void; }) { const { t } = useTranslation("artifacts"); const Icon = KIND_ICON[a.kind]; const menu = useContextMenu(); const pick = (fn: () => void) => () => { menu.close(); fn(); }; const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""); const canPreview = a.files.length > 0 && (a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))); const btn = "flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; return (
  • menu.open(e, null)} >

    {a.title}

    {t(`kindOne.${a.kind}`)} {a.source ? ` · ${a.source}` : ""} {a.size ? ` · ${fmtBytes(a.size)}` : ""}

    {a.shared && ( {t("shared")} )}
    {a.description &&

    {a.description}

    }

    {server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""} {new Date(a.createdAt).toLocaleString()} {a.installs > 0 ? ` · ${t("installs", { count: a.installs })}` : ""}

    {a.kind === "page" && a.url && ( {t("open")} )} {canPreview && ( )} {a.kind === "file" && a.files.length > 0 && ( {t("download")} )} {(a.kind === "skill" || a.kind === "plugin") && ( )} {server && a.canChange && ( )} {a.canChange && ( <> )}
    {menu.menu && ( {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")} )} {a.kind === "file" && a.files.length > 0 && ( { window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true); })} > {t("download")} )} {(a.kind === "skill" || a.kind === "plugin") && ( {t("install")} )} {a.canChange && ( <> {server && ( {t(a.shared ? "unshare" : "share")} )} {t("rename")} {t("delete")} )} )}
  • ); } function Modal({ title, onClose, children, wide = false, }: { title: string; onClose: () => void; children: React.ReactNode; wide?: boolean; }) { const { t } = useTranslation("artifacts"); const panel = useRef(null); useDialog(true, panel, onClose); return (
    e.stopPropagation()} className={`flex max-h-[85vh] w-full ${wide ? "max-w-5xl" : "max-w-lg"} flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl`} >

    {title}

    {children}
    ); } function RenameDialog({ item, onClose, onSaved, }: { item: Artifact; onClose: () => void; onSaved: () => void; }) { const { t } = useTranslation("artifacts"); 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 plugin = item.kind === "plugin"; const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary"; return (
    { e.preventDefault(); try { await api.artifacts.update(item.id, { title, description, ...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}), ...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}), }); onSaved(); } catch (err) { toast.error(err); } }} > setTitle(e.target.value)} />