feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- 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>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
@@ -0,0 +1,928 @@
/**
* @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>
);
}