7a88b74016
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
929 lines
31 KiB
TypeScript
929 lines
31 KiB
TypeScript
/**
|
||
* @file ArtifactAddPanels.tsx
|
||
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
|
||
* uploaded file (server/lib/artifacts.js):
|
||
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
|
||
* file that becomes SKILL.md, several files, a whole folder — picked or
|
||
* dropped — or a .zip/.skill; an upload holding several skills makes one
|
||
* artifact each), **Write** (name, description, instructions) and
|
||
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
|
||
* .claude/skills). The user names the skill; the name is previewed as the
|
||
* folder and `/command` it installs as (`skillSlug`, the server's rule).
|
||
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
|
||
* web link, `owner/repo` or a marketplace.json link), read its plugin list
|
||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||
import type { Lane } from "../../lib/types";
|
||
import { toast } from "../../lib/toast";
|
||
|
||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||
const hint = "text-[11px] text-fg-muted";
|
||
|
||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||
export function skillSlug(text: string): string {
|
||
return text
|
||
.normalize("NFD")
|
||
.replace(/[̀-ͯ]/g, "")
|
||
.replace(/đ/g, "d")
|
||
.replace(/Đ/g, "D")
|
||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||
.replace(/^[-._]+|-+$/g, "")
|
||
.slice(0, 100);
|
||
}
|
||
|
||
/** `field:` of a Markdown file's frontmatter, or "". */
|
||
export function frontmatterField(text: string, name: string): string {
|
||
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||
if (!fm) return "";
|
||
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
|
||
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
|
||
}
|
||
|
||
type Picked = { file: File; path: string };
|
||
|
||
/** A small file's text ("" on failure), only to prefill the name/description. */
|
||
function readText(file: File): Promise<string> {
|
||
if (file.size > 1024 * 1024) return Promise.resolve("");
|
||
return new Promise((ok) => {
|
||
const r = new FileReader();
|
||
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
|
||
r.onerror = () => ok("");
|
||
r.readAsText(file);
|
||
});
|
||
}
|
||
|
||
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
|
||
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
|
||
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
|
||
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
|
||
|
||
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
|
||
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
|
||
const items = Array.from(e.dataTransfer.items || []);
|
||
const entries = items
|
||
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
|
||
.filter((x): x is FileSystemEntry => !!x);
|
||
if (!entries.length) {
|
||
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
|
||
}
|
||
const out: Picked[] = [];
|
||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||
if (entry.isFile) {
|
||
const file = await new Promise<File>((ok, fail) =>
|
||
(entry as FileSystemFileEntry).file(ok, fail)
|
||
);
|
||
out.push({ file, path: prefix + entry.name });
|
||
return;
|
||
}
|
||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||
// readEntries answers in batches until an empty one.
|
||
for (;;) {
|
||
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
|
||
reader.readEntries(ok, fail)
|
||
);
|
||
if (!batch.length) break;
|
||
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
|
||
}
|
||
};
|
||
for (const entry of entries) await walk(entry, "");
|
||
return out;
|
||
}
|
||
|
||
/** What an upload will make, guessed the way the server decides it. */
|
||
function describeUpload(files: Picked[]): {
|
||
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
|
||
count: number;
|
||
folder: string;
|
||
} {
|
||
const real = files.filter((f) => !JUNK.test(f.path));
|
||
if (!real.length) return { kind: "none", count: 0, folder: "" };
|
||
if (real.length === 1 && isArchive(real[0]!.path))
|
||
return { kind: "archive", count: 0, folder: "" };
|
||
const roots = [
|
||
...new Set(
|
||
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
|
||
),
|
||
].sort((a, b) => a.length - b.length);
|
||
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
|
||
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
|
||
if (!top.length) {
|
||
return real.length === 1 && isText(real[0]!.path)
|
||
? { kind: "single-md", count: 1, folder: "" }
|
||
: { kind: "no-skill-md", count: 0, folder: "" };
|
||
}
|
||
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
|
||
return { kind: "many", count: top.length, folder: "" };
|
||
}
|
||
|
||
function Tabs<T extends string>({
|
||
value,
|
||
options,
|
||
onChange,
|
||
}: {
|
||
value: T;
|
||
options: { id: T; label: string }[];
|
||
onChange: (v: T) => void;
|
||
}) {
|
||
return (
|
||
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
|
||
{options.map((o) => (
|
||
<button
|
||
key={o.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={value === o.id}
|
||
onClick={() => onChange(o.id)}
|
||
className={`flex-1 rounded px-2 py-1 text-xs ${
|
||
value === o.id
|
||
? "bg-accent-solid text-white"
|
||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** The name field with the folder/command it becomes. */
|
||
function SkillNameField({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
required,
|
||
}: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
disabled?: boolean;
|
||
required?: boolean;
|
||
}) {
|
||
const { t } = useTranslation("artifacts");
|
||
const slug = skillSlug(value);
|
||
return (
|
||
<div className="space-y-0.5">
|
||
<label className={label}>
|
||
{t("skill.name")}
|
||
{required ? " *" : ""}
|
||
<input
|
||
className={`${field} mt-0.5`}
|
||
value={value}
|
||
disabled={disabled}
|
||
placeholder="release-notes"
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
</label>
|
||
{value.trim() && (
|
||
<p className={hint} data-testid="skill-slug">
|
||
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Skill ─────────────────────────────────────────────────────────────────
|
||
|
||
export function SkillAddPanel({
|
||
lanes,
|
||
onAdded,
|
||
onClose,
|
||
}: {
|
||
lanes: Lane[];
|
||
onAdded: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useTranslation("artifacts");
|
||
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
|
||
const [busy, setBusy] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [nameTouched, setNameTouched] = useState(false);
|
||
const [description, setDescription] = useState("");
|
||
const [descTouched, setDescTouched] = useState(false);
|
||
const [title, setTitle] = useState("");
|
||
const [content, setContent] = useState("");
|
||
const [files, setFiles] = useState<Picked[]>([]);
|
||
const [dragging, setDragging] = useState(false);
|
||
const [laneId, setLaneId] = useState<number | null>(null);
|
||
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
const folderInput = useRef<HTMLInputElement>(null);
|
||
|
||
// `webkitdirectory` is not in React's typings.
|
||
useEffect(() => {
|
||
folderInput.current?.setAttribute("webkitdirectory", "");
|
||
}, [mode]);
|
||
|
||
useEffect(() => {
|
||
if (mode !== "existing") return;
|
||
api.artifacts
|
||
.skills(laneId ?? undefined)
|
||
.then((r) => setSkills(r.items))
|
||
.catch(() => setSkills([]));
|
||
}, [mode, laneId]);
|
||
|
||
const shape = useMemo(() => describeUpload(files), [files]);
|
||
|
||
// Fill the name and description from what was picked, unless typed.
|
||
useEffect(() => {
|
||
if (!files.length) return;
|
||
const md =
|
||
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
|
||
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
|
||
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
|
||
let live = true;
|
||
void (async () => {
|
||
const text = md ? await readText(md.file) : "";
|
||
if (!live) return;
|
||
const fmName = frontmatterField(text, "name");
|
||
const fmDesc = frontmatterField(text, "description");
|
||
if (!nameTouched) {
|
||
const guess =
|
||
shape.kind === "single-md"
|
||
? fmName || stem(md?.path ?? "")
|
||
: shape.kind === "one"
|
||
? fmName || shape.folder
|
||
: shape.kind === "archive"
|
||
? stem(files[0]!.path)
|
||
: "";
|
||
setName(guess);
|
||
}
|
||
if (!descTouched && fmDesc) setDescription(fmDesc);
|
||
})();
|
||
return () => {
|
||
live = false;
|
||
};
|
||
// Refill only when the pick changes, never on a typed name.
|
||
}, [files, shape]);
|
||
|
||
const pick = (list: FileList | null) => {
|
||
if (!list?.length) return;
|
||
setFiles(
|
||
Array.from(list).map((file) => ({
|
||
file,
|
||
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||
}))
|
||
);
|
||
};
|
||
|
||
const run = async (fn: () => Promise<number>) => {
|
||
setBusy(true);
|
||
try {
|
||
const n = await fn();
|
||
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
|
||
onAdded();
|
||
onClose();
|
||
} catch (err) {
|
||
toast.error(err);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const many = shape.kind === "many";
|
||
const total = files.reduce((n, f) => n + f.file.size, 0);
|
||
const meta = (
|
||
<>
|
||
<label className={label}>
|
||
{t("skill.description")}
|
||
<textarea
|
||
className={`${field} mt-0.5`}
|
||
rows={2}
|
||
value={description}
|
||
disabled={many}
|
||
placeholder={t("skill.descriptionHint")}
|
||
onChange={(e) => {
|
||
setDescTouched(true);
|
||
setDescription(e.target.value);
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className={label}>
|
||
{t("form.title")}
|
||
<input
|
||
className={`${field} mt-0.5`}
|
||
value={title}
|
||
disabled={many}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
</label>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Tabs
|
||
value={mode}
|
||
onChange={setMode}
|
||
options={[
|
||
{ id: "upload", label: t("skill.tab.upload") },
|
||
{ id: "write", label: t("skill.tab.write") },
|
||
{ id: "existing", label: t("skill.tab.existing") },
|
||
]}
|
||
/>
|
||
|
||
{mode === "upload" && (
|
||
<form
|
||
className="space-y-2"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void run(async () => {
|
||
const r = await api.artifacts.uploadSkills(files, {
|
||
name: many ? undefined : name.trim(),
|
||
description: many ? undefined : description.trim(),
|
||
title: many ? undefined : title.trim(),
|
||
});
|
||
return r.items.length;
|
||
});
|
||
}}
|
||
>
|
||
<div
|
||
data-testid="skill-drop"
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragging(true);
|
||
}}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
void droppedFiles(e).then((list) => list.length && setFiles(list));
|
||
}}
|
||
className={`rounded-lg border border-dashed p-4 text-center ${
|
||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||
}`}
|
||
>
|
||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||
<button
|
||
type="button"
|
||
className="btn-ghost text-xs"
|
||
onClick={() => fileInput.current?.click()}
|
||
>
|
||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn-ghost text-xs"
|
||
onClick={() => folderInput.current?.click()}
|
||
>
|
||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref={fileInput}
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
data-testid="skill-files"
|
||
onChange={(e) => {
|
||
pick(e.target.files);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
<input
|
||
ref={folderInput}
|
||
type="file"
|
||
multiple
|
||
hidden
|
||
data-testid="skill-folder"
|
||
onChange={(e) => {
|
||
pick(e.target.files);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{files.length > 0 && (
|
||
<div className="rounded border border-border bg-surface-2 p-2">
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
|
||
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
|
||
</p>
|
||
<span className={hint}>
|
||
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={t("skill.clear")}
|
||
className="text-fg-muted hover:text-fg-primary"
|
||
onClick={() => {
|
||
setFiles([]);
|
||
setNameTouched(false);
|
||
setDescTouched(false);
|
||
}}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
|
||
{files.slice(0, 200).map((f) => (
|
||
<li
|
||
key={f.path}
|
||
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
|
||
>
|
||
{f.path}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{many ? (
|
||
<p className={hint}>{t("skill.manyHint")}</p>
|
||
) : (
|
||
<SkillNameField
|
||
value={name}
|
||
onChange={(v) => {
|
||
setNameTouched(true);
|
||
setName(v);
|
||
}}
|
||
required={shape.kind === "single-md"}
|
||
/>
|
||
)}
|
||
{meta}
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="btn-primary text-sm"
|
||
disabled={
|
||
busy ||
|
||
!files.length ||
|
||
shape.kind === "no-skill-md" ||
|
||
(shape.kind === "single-md" && !skillSlug(name))
|
||
}
|
||
>
|
||
{busy ? t("skill.uploading") : t("form.save")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{mode === "write" && (
|
||
<form
|
||
className="space-y-2"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void run(async () => {
|
||
await api.artifacts.writeSkill({
|
||
name: name.trim(),
|
||
description: description.trim(),
|
||
content,
|
||
title: title.trim() || undefined,
|
||
});
|
||
return 1;
|
||
});
|
||
}}
|
||
>
|
||
<SkillNameField value={name} onChange={setName} required />
|
||
<label className={label}>
|
||
{t("skill.description")} *
|
||
<textarea
|
||
className={`${field} mt-0.5`}
|
||
rows={2}
|
||
value={description}
|
||
placeholder={t("skill.descriptionHint")}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label className={label}>
|
||
{t("skill.content")} *
|
||
<textarea
|
||
className={`${field} mt-0.5 font-mono text-xs`}
|
||
rows={10}
|
||
value={content}
|
||
placeholder={t("skill.contentHint")}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label className={label}>
|
||
{t("form.title")}
|
||
<input
|
||
className={`${field} mt-0.5`}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="btn-primary text-sm"
|
||
disabled={
|
||
busy ||
|
||
!skillSlug(name) ||
|
||
!content.trim() ||
|
||
(!description.trim() && !frontmatterField(content, "description"))
|
||
}
|
||
>
|
||
{t("form.save")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
{mode === "existing" && (
|
||
<div className="space-y-2">
|
||
<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>
|
||
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
|
||
{skills && skills.length > 0 && (
|
||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||
{skills.map((s) => (
|
||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||
<span className="text-fg-muted">
|
||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn-ghost text-xs"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
void run(async () => {
|
||
await api.artifacts.addSkill({
|
||
name: s.name,
|
||
...(s.from === "workspace" && laneId ? { laneId } : {}),
|
||
title: title || undefined,
|
||
description: description || undefined,
|
||
});
|
||
return 1;
|
||
})
|
||
}
|
||
>
|
||
{t("form.save")}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<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)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Plugin ────────────────────────────────────────────────────────────────
|
||
|
||
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
|
||
|
||
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
|
||
const { t } = useTranslation("artifacts");
|
||
const [link, setLink] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||
const [have, setHave] = useState<Set<string>>(new Set());
|
||
const [manual, setManual] = useState(false);
|
||
const [key, setKey] = useState("");
|
||
const [market, setMarket] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
|
||
useEffect(() => {
|
||
api.artifacts
|
||
.list({ scope: "mine", kind: "plugin" })
|
||
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
|
||
.catch(() => setHave(new Set()));
|
||
}, []);
|
||
|
||
const read = async () => {
|
||
setBusy(true);
|
||
setError(null);
|
||
setInfo(null);
|
||
try {
|
||
const r = await api.artifacts.inspectMarket(link.trim());
|
||
setInfo(r);
|
||
setPicked(
|
||
new Set(
|
||
r.plugins
|
||
.filter((p) => !have.has(p.key))
|
||
.slice(0, 1)
|
||
.map((p) => p.key)
|
||
)
|
||
);
|
||
setMarket(r.market);
|
||
} catch (err) {
|
||
const e = err as Error & { code?: string };
|
||
setError(e.message);
|
||
if (MANUAL_CODES.has(e.code ?? "")) {
|
||
setManual(true);
|
||
setMarket(link.trim());
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const addPicked = async () => {
|
||
if (!info) return;
|
||
setBusy(true);
|
||
let n = 0;
|
||
try {
|
||
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
|
||
await api.artifacts.addPlugin({
|
||
key: p.key,
|
||
market: info.market,
|
||
title: p.name,
|
||
description: p.description,
|
||
});
|
||
n++;
|
||
}
|
||
toast.success(t("plugin.addedMany", { count: n }));
|
||
onAdded();
|
||
onClose();
|
||
} catch (err) {
|
||
toast.error(err);
|
||
if (n) onAdded();
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const toggle = (k: string) =>
|
||
setPicked((s) => {
|
||
const next = new Set(s);
|
||
if (next.has(k)) next.delete(k);
|
||
else next.add(k);
|
||
return next;
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<form
|
||
className="space-y-1"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (link.trim()) void read();
|
||
}}
|
||
>
|
||
<label className={label}>
|
||
{t("plugin.link")}
|
||
<div className="mt-0.5 flex gap-2">
|
||
<input
|
||
className={field}
|
||
value={link}
|
||
autoFocus
|
||
placeholder="https://git.example.com/org/claude-skills.git"
|
||
onChange={(e) => setLink(e.target.value)}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary shrink-0 text-sm"
|
||
disabled={busy || !link.trim()}
|
||
>
|
||
{busy && !info ? t("plugin.reading") : t("plugin.read")}
|
||
</button>
|
||
</div>
|
||
</label>
|
||
<p className={hint}>{t("plugin.linkHint")}</p>
|
||
</form>
|
||
|
||
{error && (
|
||
<p
|
||
role="alert"
|
||
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
|
||
>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{info && (
|
||
<div className="space-y-2" data-testid="market-plugins">
|
||
<div>
|
||
<p className="text-sm font-medium text-fg-primary">
|
||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||
{info.name}
|
||
</p>
|
||
{info.description && <p className={hint}>{info.description}</p>}
|
||
</div>
|
||
{info.plugins.length === 0 ? (
|
||
<p className={hint}>{t("plugin.none")}</p>
|
||
) : (
|
||
<>
|
||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||
<input
|
||
type="checkbox"
|
||
checked={picked.size === info.plugins.length}
|
||
onChange={(e) =>
|
||
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
|
||
}
|
||
/>
|
||
{t("plugin.all")}
|
||
</label>
|
||
<ul className="max-h-64 space-y-1 overflow-auto">
|
||
{info.plugins.map((p) => (
|
||
<li key={p.key}>
|
||
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
|
||
<input
|
||
type="checkbox"
|
||
className="mt-0.5"
|
||
checked={picked.has(p.key)}
|
||
onChange={() => toggle(p.key)}
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
|
||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
|
||
{p.version && (
|
||
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
|
||
)}
|
||
{have.has(p.key) && (
|
||
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||
{t("plugin.have")}
|
||
</span>
|
||
)}
|
||
{p.description && (
|
||
<span className="block text-[11px] text-fg-muted">{p.description}</span>
|
||
)}
|
||
</span>
|
||
</label>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
className="btn-primary text-sm"
|
||
disabled={busy || picked.size === 0}
|
||
onClick={() => void addPicked()}
|
||
>
|
||
{t("plugin.addPicked", { count: picked.size })}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="border-t border-border-light pt-2">
|
||
<button
|
||
type="button"
|
||
className="text-xs text-accent hover:underline"
|
||
aria-expanded={manual}
|
||
onClick={() => setManual((v) => !v)}
|
||
>
|
||
{t("plugin.manual")}
|
||
</button>
|
||
{manual && (
|
||
<form
|
||
className="mt-2 space-y-2"
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
try {
|
||
await api.artifacts.addPlugin({
|
||
key: key.trim(),
|
||
market: market.trim(),
|
||
title,
|
||
description,
|
||
});
|
||
toast.success(t("added"));
|
||
onAdded();
|
||
onClose();
|
||
} catch (err) {
|
||
toast.error(err);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
<input
|
||
className={field}
|
||
placeholder="my-plugin@my-marketplace"
|
||
aria-label={t("form.pluginKey")}
|
||
value={key}
|
||
onChange={(e) => setKey(e.target.value)}
|
||
/>
|
||
<input
|
||
className={field}
|
||
placeholder="owner/repo · https://… · git@host:org/repo.git"
|
||
aria-label={t("form.market")}
|
||
value={market}
|
||
onChange={(e) => setMarket(e.target.value)}
|
||
/>
|
||
<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)}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
className="btn-primary text-sm"
|
||
disabled={busy || !key.trim() || !market.trim()}
|
||
>
|
||
{t("form.save")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── File upload ───────────────────────────────────────────────────────────
|
||
|
||
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||
const { t } = useTranslation("artifacts");
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [title, setTitle] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const tooBig = !!file && file.size > 25 * 1024 * 1024;
|
||
return (
|
||
<form
|
||
className="space-y-2"
|
||
onSubmit={async (e) => {
|
||
e.preventDefault();
|
||
if (!file) return;
|
||
setBusy(true);
|
||
try {
|
||
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
|
||
toast.success(t("added"));
|
||
setFile(null);
|
||
setTitle("");
|
||
onAdded();
|
||
} catch (err) {
|
||
toast.error(err);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
<label className={label}>
|
||
{t("file.fromComputer")}
|
||
<input
|
||
type="file"
|
||
data-testid="file-upload"
|
||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||
/>
|
||
</label>
|
||
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
|
||
<div className="flex gap-2">
|
||
<input
|
||
className={field}
|
||
placeholder={t("form.title")}
|
||
aria-label={t("form.title")}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary shrink-0 text-sm"
|
||
disabled={busy || !file || tooBig}
|
||
>
|
||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|