176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { html, nothing } from "lit";
|
|
import { t } from "../i18n";
|
|
|
|
import { clampText } from "../format";
|
|
import type { SkillStatusEntry, SkillStatusReport } from "../types";
|
|
import type { SkillMessageMap } from "../controllers/skills";
|
|
|
|
export type SkillsProps = {
|
|
loading: boolean;
|
|
report: SkillStatusReport | null;
|
|
error: string | null;
|
|
filter: string;
|
|
edits: Record<string, string>;
|
|
busyKey: string | null;
|
|
messages: SkillMessageMap;
|
|
onFilterChange: (next: string) => void;
|
|
onRefresh: () => void;
|
|
onToggle: (skillKey: string, enabled: boolean) => void;
|
|
onEdit: (skillKey: string, value: string) => void;
|
|
onSaveKey: (skillKey: string) => void;
|
|
onInstall: (skillKey: string, name: string, installId: string) => void;
|
|
};
|
|
|
|
export function renderSkills(props: SkillsProps) {
|
|
const skills = props.report?.skills ?? [];
|
|
const filter = props.filter.trim().toLowerCase();
|
|
const filtered = filter
|
|
? skills.filter((skill) =>
|
|
[skill.name, skill.description, skill.source]
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(filter),
|
|
)
|
|
: skills;
|
|
|
|
return html`
|
|
<section class="card">
|
|
<div class="row" style="justify-content: space-between;">
|
|
<div>
|
|
<div class="card-title">${t("skills.title")}</div>
|
|
<div class="card-sub">${t("skills.subtitle")}</div>
|
|
</div>
|
|
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
|
${props.loading ? t("common.loading") : t("common.refresh")}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="filters" style="margin-top: 14px;">
|
|
<label class="field" style="flex: 1;">
|
|
<span>${t("skills.filter")}</span>
|
|
<input
|
|
.value=${props.filter}
|
|
@input=${(e: Event) =>
|
|
props.onFilterChange((e.target as HTMLInputElement).value)}
|
|
placeholder=${t("skills.searchPlaceholder")}
|
|
/>
|
|
</label>
|
|
<div class="muted">${t("skills.shown", { count: filtered.length })}</div>
|
|
</div>
|
|
|
|
${props.error
|
|
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
|
: nothing}
|
|
|
|
${filtered.length === 0
|
|
? html`<div class="muted" style="margin-top: 16px;">${t("skills.noSkills")}</div>`
|
|
: html`
|
|
<div class="list" style="margin-top: 16px;">
|
|
${filtered.map((skill) => renderSkill(skill, props))}
|
|
</div>
|
|
`}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|
const busy = props.busyKey === skill.skillKey;
|
|
const apiKey = props.edits[skill.skillKey] ?? "";
|
|
const message = props.messages[skill.skillKey] ?? null;
|
|
const canInstall =
|
|
skill.install.length > 0 && skill.missing.bins.length > 0;
|
|
const missing = [
|
|
...skill.missing.bins.map((b) => `bin:${b}`),
|
|
...skill.missing.env.map((e) => `env:${e}`),
|
|
...skill.missing.config.map((c) => `config:${c}`),
|
|
...skill.missing.os.map((o) => `os:${o}`),
|
|
];
|
|
const reasons: string[] = [];
|
|
if (skill.disabled) reasons.push(t("skills.reasonDisabled"));
|
|
if (skill.blockedByAllowlist) reasons.push(t("skills.reasonAllowlist"));
|
|
return html`
|
|
<div class="list-item">
|
|
<div class="list-main">
|
|
<div class="list-title">
|
|
${skill.emoji ? `${skill.emoji} ` : ""}${t(`skills.names.${skill.skillKey}`) !== `skills.names.${skill.skillKey}` ? t(`skills.names.${skill.skillKey}`) : skill.name}
|
|
</div>
|
|
<div class="list-sub">${clampText((t(`skills.descriptions.${skill.skillKey}`) !== `skills.descriptions.${skill.skillKey}` ? t(`skills.descriptions.${skill.skillKey}`) : skill.description), 140)}</div>
|
|
<div class="chip-row" style="margin-top: 6px;">
|
|
<span class="chip">${skill.source}</span>
|
|
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
|
|
${skill.eligible ? t("skills.eligible") : t("skills.blocked")}
|
|
</span>
|
|
${skill.disabled ? html`<span class="chip chip-warn">${t("skills.disabled")}</span>` : nothing}
|
|
</div>
|
|
${missing.length > 0
|
|
? html`
|
|
<div class="muted" style="margin-top: 6px;">
|
|
${t("skills.missing")} ${missing.join(", ")}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${reasons.length > 0
|
|
? html`
|
|
<div class="muted" style="margin-top: 6px;">
|
|
${t("skills.reason")} ${reasons.join(", ")}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
<div class="list-meta">
|
|
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
|
|
<button
|
|
class="btn"
|
|
?disabled=${busy}
|
|
@click=${() => props.onToggle(skill.skillKey, skill.disabled)}
|
|
>
|
|
${skill.disabled ? t("skills.enable") : t("skills.disable")}
|
|
</button>
|
|
${canInstall
|
|
? html`<button
|
|
class="btn"
|
|
?disabled=${busy}
|
|
@click=${() =>
|
|
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
|
|
>
|
|
${busy ? t("skills.installing") : skill.install[0].label}
|
|
</button>`
|
|
: nothing}
|
|
</div>
|
|
${message
|
|
? html`<div
|
|
class="muted"
|
|
style="margin-top: 8px; color: ${message.kind === "error"
|
|
? "var(--danger-color, #d14343)"
|
|
: "var(--success-color, #0a7f5a)"
|
|
};"
|
|
>
|
|
${message.message}
|
|
</div>`
|
|
: nothing}
|
|
${skill.primaryEnv
|
|
? html`
|
|
<div class="field" style="margin-top: 10px;">
|
|
<span>${t("skills.apiKey")}</span>
|
|
<input
|
|
type="password"
|
|
.value=${apiKey}
|
|
@input=${(e: Event) =>
|
|
props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
class="btn primary"
|
|
style="margin-top: 8px;"
|
|
?disabled=${busy}
|
|
@click=${() => props.onSaveKey(skill.skillKey)}
|
|
>
|
|
${t("skills.saveKey")}
|
|
</button>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|