a58e95a957
- Settings → Git → Your git identity: SSH keys are one tab per host kind (GitHub · GitLab, AWS CodeCommit, More keys, Other hosts); opens on the AWS tab when only an RSA key exists. - Artifacts: a file type the sandboxed preview cannot show (Office, zip, PDF) offers Download only, never a blank Preview. - Artifacts search: `%`/`_`/`\` in a query match literally (LIKE ... ESCAPE). - Skill frontmatter rewrite replaces a key with its continuation lines and quotes values YAML could misread. - Plugin marketplace.json is read with a 1 MB streaming cap. - Artifact lists look each owner up once.
868 lines
28 KiB
TypeScript
868 lines
28 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<ArtifactSettings | null>(null);
|
|
const [tab, setTab] = useState<"mine" | "hub">("mine");
|
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
|
const [q, setQ] = useState("");
|
|
const [items, setItems] = useState<Artifact[] | null>(null);
|
|
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
|
const [addMenu, setAddMenu] = useState(false);
|
|
const addMenuRef = useRef<HTMLDivElement>(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<Artifact | null>(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 (
|
|
<div className="space-y-4" data-testid="artifacts-page">
|
|
<header className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
|
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative" ref={addMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="btn-primary flex items-center gap-1.5 text-sm"
|
|
data-testid="artifacts-add"
|
|
aria-haspopup="menu"
|
|
aria-expanded={addMenu}
|
|
onClick={() => setAddMenu((v) => !v)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t("add")}
|
|
</button>
|
|
{addMenu && (
|
|
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-surface-1 p-1 shadow-xl">
|
|
{KINDS.map((k) => {
|
|
const Icon = KIND_ICON[k];
|
|
return (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
onClick={() => {
|
|
setAdding(k);
|
|
setAddMenu(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{t(`add.${k}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{server && (
|
|
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
|
{(["mine", "hub"] as const).map((v) => (
|
|
<button
|
|
key={v}
|
|
type="button"
|
|
aria-pressed={tab === v}
|
|
onClick={() => setTab(v)}
|
|
className={`rounded px-3 py-1 text-xs font-medium ${
|
|
tab === v
|
|
? "bg-accent-solid text-white"
|
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{t(`tab.${v}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
|
{(["", ...KINDS] as const).map((k) => (
|
|
<button
|
|
key={k || "all"}
|
|
type="button"
|
|
aria-pressed={kind === k}
|
|
onClick={() => setKind(k)}
|
|
className={`rounded px-2.5 py-1 text-xs ${
|
|
kind === k
|
|
? "bg-surface-4 text-fg-primary"
|
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
}`}
|
|
>
|
|
{t(k ? `kind.${k}` : "kind.all")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
value={q}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{items === null ? (
|
|
<p className="text-sm text-fg-muted">{t("loading")}</p>
|
|
) : items.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-fg-muted">
|
|
{t(tab === "hub" ? "empty.hub" : "empty.mine")}
|
|
</p>
|
|
) : (
|
|
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-testid="artifacts-list">
|
|
{items.map((a) => (
|
|
<ArtifactCard
|
|
key={a.id}
|
|
item={a}
|
|
server={server}
|
|
canInstall={!!settings?.installsEnabled}
|
|
onShare={() => void share(a)}
|
|
onRename={() => setRenaming(a)}
|
|
onDelete={() => void remove(a)}
|
|
onInstall={() => void install(a)}
|
|
onPreview={(path) => setPreview({ item: a, path })}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
|
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
|
{renaming && (
|
|
<RenameDialog
|
|
item={renaming}
|
|
onClose={() => setRenaming(null)}
|
|
onSaved={() => {
|
|
setRenaming(null);
|
|
load();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<null>();
|
|
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 (
|
|
<li
|
|
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
|
|
data-testid={`artifact-${a.id}`}
|
|
onContextMenu={(e) => menu.open(e, null)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-fg-primary" title={a.title}>
|
|
{a.title}
|
|
</p>
|
|
<p className="truncate text-[11px] text-fg-muted" title={a.source || a.url || ""}>
|
|
{t(`kindOne.${a.kind}`)}
|
|
{a.source ? ` · ${a.source}` : ""}
|
|
{a.size ? ` · ${fmtBytes(a.size)}` : ""}
|
|
</p>
|
|
</div>
|
|
{a.shared && (
|
|
<span className="shrink-0 rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
|
{t("shared")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{a.description && <p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>}
|
|
<p className="text-[11px] text-fg-muted">
|
|
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
|
|
{new Date(a.createdAt).toLocaleString()}
|
|
{a.installs > 0 ? ` · ${t("installs", { count: a.installs })}` : ""}
|
|
</p>
|
|
<div className="mt-auto flex flex-wrap items-center gap-1 border-t border-border-light pt-2">
|
|
{a.kind === "page" && a.url && (
|
|
<a href={a.url} target="_blank" rel="noopener noreferrer" className={btn}>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t("open")}
|
|
</a>
|
|
)}
|
|
{canPreview && (
|
|
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
|
|
<Eye className="h-3 w-3" />
|
|
{t("preview")}
|
|
</button>
|
|
)}
|
|
{a.kind === "file" && a.files.length > 0 && (
|
|
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={btn}>
|
|
<Download className="h-3 w-3" />
|
|
{t("download")}
|
|
</a>
|
|
)}
|
|
{(a.kind === "skill" || a.kind === "plugin") && (
|
|
<button
|
|
type="button"
|
|
className={btn}
|
|
disabled={!canInstall}
|
|
title={canInstall ? undefined : t("installsOff")}
|
|
onClick={onInstall}
|
|
>
|
|
<Download className="h-3 w-3" />
|
|
{t("install")}
|
|
</button>
|
|
)}
|
|
<span className="ml-auto flex items-center gap-1">
|
|
{server && a.canChange && (
|
|
<button type="button" className={btn} onClick={onShare}>
|
|
<Share2 className="h-3 w-3" />
|
|
{t(a.shared ? "unshare" : "share")}
|
|
</button>
|
|
)}
|
|
{a.canChange && (
|
|
<>
|
|
<button type="button" className={btn} aria-label={t("rename")} onClick={onRename}>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
<button type="button" className={btn} aria-label={t("delete")} onClick={onDelete}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
{menu.menu && (
|
|
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
|
|
{a.kind === "page" && a.url && (
|
|
<>
|
|
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
|
|
{t("open")}
|
|
</MenuItem>
|
|
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
|
|
</>
|
|
)}
|
|
{canPreview && (
|
|
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
|
|
)}
|
|
{a.kind === "file" && a.files.length > 0 && (
|
|
<MenuItem
|
|
onSelect={pick(() => {
|
|
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
|
|
})}
|
|
>
|
|
{t("download")}
|
|
</MenuItem>
|
|
)}
|
|
{(a.kind === "skill" || a.kind === "plugin") && (
|
|
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
|
|
{t("install")}
|
|
</MenuItem>
|
|
)}
|
|
{a.canChange && (
|
|
<>
|
|
<MenuSeparator />
|
|
{server && (
|
|
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
|
|
)}
|
|
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
|
<MenuItem danger onSelect={pick(onDelete)}>
|
|
{t("delete")}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</ContextMenu>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function Modal({
|
|
title,
|
|
onClose,
|
|
children,
|
|
wide = false,
|
|
}: {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
wide?: boolean;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const panel = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panel, onClose);
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
|
role="presentation"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={panel}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
onClick={(e) => 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`}
|
|
>
|
|
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
|
<h2 className="truncate text-sm font-semibold text-fg-primary">{title}</h2>
|
|
<button
|
|
type="button"
|
|
aria-label={t("close")}
|
|
onClick={onClose}
|
|
className="text-fg-muted hover:text-fg-primary"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Modal title={t("rename")} onClose={onClose}>
|
|
<form
|
|
className="space-y-2"
|
|
onSubmit={async (e) => {
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
<input
|
|
className={field}
|
|
aria-label={t("form.title")}
|
|
value={title}
|
|
autoFocus
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
<textarea
|
|
className={field}
|
|
rows={3}
|
|
aria-label={t("form.description")}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
{plugin && (
|
|
<>
|
|
<input
|
|
className={field}
|
|
aria-label={t("form.pluginKey")}
|
|
placeholder="my-plugin@my-marketplace"
|
|
value={source}
|
|
onChange={(e) => setSource(e.target.value)}
|
|
/>
|
|
<input
|
|
className={field}
|
|
aria-label={t("form.market")}
|
|
placeholder="owner/repo · https://…"
|
|
value={market}
|
|
onChange={(e) => setMarket(e.target.value)}
|
|
/>
|
|
</>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
className="btn-primary text-sm"
|
|
disabled={!title.trim() || (plugin && (!source.trim() || !market.trim()))}
|
|
>
|
|
{t("form.saveEdit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function PreviewDialog({
|
|
item,
|
|
path,
|
|
onClose,
|
|
}: {
|
|
item: Artifact;
|
|
path: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [current, setCurrent] = useState(path);
|
|
const url = api.artifacts.rawUrl(item.id, current);
|
|
return (
|
|
<Modal title={item.title} onClose={onClose} wide>
|
|
<div className="flex h-[70vh] gap-3">
|
|
{item.files.length > 1 && (
|
|
<ul className="w-56 shrink-0 space-y-0.5 overflow-auto">
|
|
{item.files.map((f) => (
|
|
<li key={f.path}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrent(f.path)}
|
|
className={`w-full truncate rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
|
|
f.path === current
|
|
? "bg-surface-3 text-fg-primary"
|
|
: "text-fg-secondary hover:bg-surface-2"
|
|
}`}
|
|
title={f.path}
|
|
>
|
|
{f.path}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="min-w-0 flex-1 rounded border border-border bg-white">
|
|
{!PREVIEWABLE.test(current) ? (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center">
|
|
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
|
|
<a
|
|
href={api.artifacts.rawUrl(item.id, current, true)}
|
|
className="btn-primary flex items-center gap-1.5 text-sm"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
{t("download")}
|
|
</a>
|
|
</div>
|
|
) : IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
|
<img src={url} alt={current} className="h-full w-full object-contain" />
|
|
) : (
|
|
// No allow-* tokens: a shared page renders, nothing in it runs.
|
|
<iframe
|
|
title={current}
|
|
src={url}
|
|
sandbox=""
|
|
className="h-full w-full"
|
|
data-testid="artifact-preview"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function AddDialog({
|
|
kind,
|
|
onClose,
|
|
onAdded,
|
|
}: {
|
|
kind: ArtifactKind;
|
|
onClose: () => void;
|
|
onAdded: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [busy, setBusy] = useState(false);
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
|
const [laneId, setLaneId] = useState<number | null>(null);
|
|
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (kind !== "file" && kind !== "skill") return;
|
|
api.lanes
|
|
.list()
|
|
.then((r) => setLanes(r.lanes))
|
|
.catch(() => setLanes([]));
|
|
}, [kind]);
|
|
|
|
useEffect(() => {
|
|
if (kind === "file") {
|
|
setCandidates(null);
|
|
if (laneId)
|
|
api.artifacts
|
|
.candidates(laneId)
|
|
.then((r) => setCandidates(r.items))
|
|
.catch((err) => {
|
|
setCandidates([]);
|
|
toast.error(err);
|
|
});
|
|
}
|
|
}, [kind, laneId]);
|
|
|
|
const run = async (fn: () => Promise<unknown>, close = true) => {
|
|
setBusy(true);
|
|
try {
|
|
await fn();
|
|
toast.success(t("added"));
|
|
onAdded();
|
|
if (close) onClose();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const field =
|
|
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
|
const meta = (
|
|
<>
|
|
<input
|
|
className={field}
|
|
placeholder={t("form.title")}
|
|
aria-label={t("form.title")}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
<textarea
|
|
className={field}
|
|
rows={2}
|
|
placeholder={t("form.description")}
|
|
aria-label={t("form.description")}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</>
|
|
);
|
|
const workspacePicker = (
|
|
<select
|
|
className={field}
|
|
aria-label={t("form.workspace")}
|
|
value={laneId ?? ""}
|
|
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
|
>
|
|
<option value="">{t("form.pickWorkspace")}</option>
|
|
{lanes.map((l) => (
|
|
<option key={l.id} value={l.id}>
|
|
{l.title || l.cwd}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
|
|
return (
|
|
<Modal title={t(`add.${kind}`)} onClose={onClose}>
|
|
<div className="space-y-2" data-testid={`artifacts-add-${kind}`}>
|
|
{kind === "page" && (
|
|
<form
|
|
className="space-y-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void run(() => api.artifacts.addPage({ url, title, description }));
|
|
}}
|
|
>
|
|
<input
|
|
className={field}
|
|
placeholder="https://claude.ai/…"
|
|
aria-label={t("form.url")}
|
|
value={url}
|
|
autoFocus
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
/>
|
|
{meta}
|
|
<div className="flex justify-end">
|
|
<button type="submit" className="btn-primary text-sm" disabled={busy || !url.trim()}>
|
|
{t("form.save")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{kind === "file" && (
|
|
<>
|
|
<FileUploadPanel onAdded={onAdded} />
|
|
<p className="border-t border-border-light pt-2 text-[11px] font-medium text-fg-secondary">
|
|
{t("file.fromWorkspace")}
|
|
</p>
|
|
{workspacePicker}
|
|
{laneId && candidates === null && (
|
|
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
|
)}
|
|
{candidates && candidates.length === 0 && (
|
|
<p className="text-xs text-fg-muted">{t("form.noCandidates")}</p>
|
|
)}
|
|
{candidates && candidates.length > 0 && (
|
|
<ul className="max-h-80 space-y-0.5 overflow-auto">
|
|
{candidates.map((c) => (
|
|
<li key={c.path} className="flex items-center gap-2 text-xs">
|
|
<span
|
|
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
|
|
title={c.path}
|
|
>
|
|
{c.path}
|
|
</span>
|
|
{!c.exists ? (
|
|
<span className="text-fg-muted">{t("form.missing")}</span>
|
|
) : c.pinned ? (
|
|
<span className="text-fg-muted">{t("form.pinned")}</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn-ghost text-xs"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(async () => {
|
|
await api.artifacts.pinFile({
|
|
laneId: laneId!,
|
|
path: c.path,
|
|
sessionId: c.sessionId,
|
|
});
|
|
setCandidates((list) =>
|
|
(list ?? []).map((x) =>
|
|
x.path === c.path ? { ...x, pinned: true } : x
|
|
)
|
|
);
|
|
}, false)
|
|
}
|
|
>
|
|
{t("form.pin")}
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
|
|
|
|
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|