Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1024 lines
39 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
Eye,
Loader2,
Lock,
Pencil,
Plus,
Sparkles,
Store,
Tag as TagIcon,
Trash2,
Wand2,
X,
} from "lucide-react";
import SpaceSectionHeader from "@/components/space/SpaceSectionHeader";
import EduHubImportModal from "@/components/space/EduHubImportModal";
import { isValidSkillName, slugifySkillName } from "@/lib/skill-slug";
import {
createSkill,
createSkillTag,
deleteSkill,
deleteSkillTag,
getSkill,
listSkillTags,
listSkills,
renameSkillTag,
updateSkill,
type SkillInfo,
} from "@/lib/skills-api";
interface SkillEditorState {
mode: "create" | "edit";
originalName: string | null;
name: string;
description: string;
content: string;
tags: string[];
saving: boolean;
error: string | null;
}
interface SkillViewerState {
name: string;
source?: string;
readOnly: boolean;
description: string;
content: string;
loading: boolean;
error: string | null;
}
// Lazy-load the markdown renderer so the heavier markdown deps only ship
// when a user actually opens a skill viewer (matches MemorySection).
const SkillMarkdown = dynamic(
() => import("@/components/common/SimpleMarkdownRenderer"),
{ ssr: false },
);
function normalizeTag(value: string): string {
return value.trim().toLowerCase();
}
/** Drop the YAML frontmatter block so the viewer shows just the playbook. */
function stripFrontmatter(md: string): string {
const match = md.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
return match ? md.slice(match[0].length) : md;
}
export default function SkillsSection() {
const { t } = useTranslation();
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [tagVocab, setTagVocab] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [editor, setEditor] = useState<SkillEditorState | null>(null);
const [viewer, setViewer] = useState<SkillViewerState | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [filterTag, setFilterTag] = useState<string | "all" | "untagged">(
"all",
);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const [newTagDraft, setNewTagDraft] = useState("");
const [renamingTag, setRenamingTag] = useState<{
original: string;
value: string;
} | null>(null);
const [editorTagDraft, setEditorTagDraft] = useState("");
const load = useCallback(async () => {
setLoading(true);
setErrorMsg(null);
try {
const [items, tags] = await Promise.all([
listSkills({ force: true }),
listSkillTags({ force: true }),
]);
setSkills(items);
setTagVocab(tags);
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const filteredSkills = useMemo(() => {
if (filterTag === "all") return skills;
if (filterTag === "untagged") {
return skills.filter((s) => !s.tags || s.tags.length === 0);
}
return skills.filter((s) => s.tags?.includes(filterTag));
}, [filterTag, skills]);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const s of skills) {
for (const tag of s.tags ?? []) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [skills]);
const untaggedCount = useMemo(
() => skills.filter((s) => !s.tags || s.tags.length === 0).length,
[skills],
);
// ── editor handlers ───────────────────────────────────────────────
const openCreate = useCallback(() => {
setEditor({
mode: "create",
originalName: null,
name: "",
description: "",
content:
"# My Skill\n\nDescribe how the assistant should behave when this skill is active.\n",
tags: [],
saving: false,
error: null,
});
setEditorTagDraft("");
}, []);
const openEdit = useCallback(async (name: string) => {
setEditor({
mode: "edit",
originalName: name,
name,
description: "",
content: "",
tags: [],
saving: true,
error: null,
});
setEditorTagDraft("");
try {
const detail = await getSkill(name);
setEditor({
mode: "edit",
originalName: name,
name: detail.name,
description: detail.description,
content: detail.content,
tags: [...(detail.tags ?? [])],
saving: false,
error: null,
});
} catch (err) {
setEditor((prev) =>
prev
? {
...prev,
saving: false,
error: err instanceof Error ? err.message : String(err),
}
: prev,
);
}
}, []);
const openView = useCallback(async (skill: SkillInfo) => {
setViewer({
name: skill.name,
source: skill.source,
readOnly: Boolean(skill.read_only),
description: skill.description,
content: "",
loading: true,
error: null,
});
try {
const detail = await getSkill(skill.name);
setViewer({
name: detail.name,
source: detail.source ?? skill.source,
readOnly: Boolean(detail.read_only),
description: detail.description,
content: detail.content,
loading: false,
error: null,
});
} catch (err) {
setViewer((prev) =>
prev
? {
...prev,
loading: false,
error: err instanceof Error ? err.message : String(err),
}
: prev,
);
}
}, []);
const handleSave = useCallback(async () => {
if (!editor) return;
const trimmedName = editor.name.trim();
if (!trimmedName) {
setEditor({ ...editor, error: t("Name is required") });
return;
}
if (!isValidSkillName(trimmedName)) {
setEditor({
...editor,
error: t(
"Name must use only lowercase letters, digits, and hyphens, and must start with a letter or digit.",
),
});
return;
}
setEditor({ ...editor, saving: true, error: null });
try {
if (editor.mode === "create") {
await createSkill({
name: trimmedName,
description: editor.description,
content: editor.content,
tags: editor.tags,
});
} else if (editor.originalName) {
await updateSkill(editor.originalName, {
description: editor.description,
content: editor.content,
tags: editor.tags,
rename_to:
trimmedName !== editor.originalName ? trimmedName : undefined,
});
}
setEditor(null);
await load();
} catch (err) {
setEditor((prev) =>
prev
? {
...prev,
saving: false,
error: err instanceof Error ? err.message : String(err),
}
: prev,
);
}
}, [editor, load, t]);
const handleDelete = useCallback(
async (name: string) => {
if (!window.confirm(t('Delete skill "{{name}}"?', { name }))) return;
setDeleting(name);
try {
await deleteSkill(name);
await load();
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
} finally {
setDeleting(null);
}
},
[load, t],
);
const toggleEditorTag = useCallback((tag: string) => {
const clean = normalizeTag(tag);
if (!clean) return;
setEditor((prev) => {
if (!prev) return prev;
const has = prev.tags.includes(clean);
return {
...prev,
tags: has
? prev.tags.filter((t) => t !== clean)
: [...prev.tags, clean],
};
});
}, []);
const handleCreateEditorTag = useCallback(async () => {
const clean = normalizeTag(editorTagDraft);
if (!clean) return;
setEditorTagDraft("");
// add immediately to editor selection regardless of vocab outcome
setEditor((prev) =>
prev
? {
...prev,
tags: prev.tags.includes(clean) ? prev.tags : [...prev.tags, clean],
}
: prev,
);
if (tagVocab.includes(clean)) return;
try {
await createSkillTag(clean);
const next = await listSkillTags({ force: true });
setTagVocab(next);
} catch {
/* swallow — adding already-existing tag or invalid — editor still keeps it */
}
}, [editorTagDraft, tagVocab]);
// ── tag vocabulary handlers ───────────────────────────────────────
const handleCreateTag = useCallback(async () => {
const clean = normalizeTag(newTagDraft);
if (!clean) return;
try {
await createSkillTag(clean);
const next = await listSkillTags({ force: true });
setTagVocab(next);
setNewTagDraft("");
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
}
}, [newTagDraft]);
const handleRenameTag = useCallback(async () => {
if (!renamingTag) return;
const next = normalizeTag(renamingTag.value);
if (!next || next === renamingTag.original) {
setRenamingTag(null);
return;
}
try {
await renameSkillTag(renamingTag.original, next);
if (filterTag === renamingTag.original) {
setFilterTag(next);
}
await load();
setRenamingTag(null);
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
}
}, [filterTag, load, renamingTag]);
const handleDeleteTag = useCallback(
async (tag: string) => {
if (!window.confirm(t('Delete tag "{{name}}"?', { name: tag }))) return;
try {
await deleteSkillTag(tag);
if (filterTag === tag) setFilterTag("all");
await load();
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : String(err));
}
},
[filterTag, load, t],
);
// ── render ────────────────────────────────────────────────────────
const availableTagsForEditor = useMemo(
() =>
tagVocab.concat(
(editor?.tags ?? []).filter((t) => !tagVocab.includes(t)),
),
[editor?.tags, tagVocab],
);
const editorNameInvalid = Boolean(
editor?.name && !isValidSkillName(editor.name),
);
return (
<div className="space-y-6">
<SpaceSectionHeader
icon={Wand2}
title={t("Skills")}
description={t(
"Capability playbooks the model reads on demand. Built-in and preset skills are read-only.",
)}
meta={
<span className="rounded-full border border-[var(--border)] bg-[var(--card)] px-2 py-0.5 text-[10.5px] font-medium text-[var(--muted-foreground)]">
{skills.length} {t("skills.count.suffix")}
</span>
}
action={
<div className="flex items-center gap-2">
<button
onClick={() => setImportOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--foreground)] shadow-sm transition-colors hover:bg-[var(--muted)]/50"
>
<Store size={13} strokeWidth={1.9} />
{t("Import from EduHub")}
</button>
<button
onClick={openCreate}
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--primary-foreground)] shadow-sm transition-opacity hover:opacity-90"
>
<Plus size={13} strokeWidth={2} />
{t("New skill")}
</button>
</div>
}
/>
{/* Tag manager (collapsible) */}
<section
className={`overflow-hidden rounded-xl border transition-colors ${
tagManagerOpen
? "border-[var(--border)] bg-[var(--card)] shadow-sm"
: "border-[var(--border)]/60 bg-[var(--card)]/40"
}`}
>
<button
onClick={() => setTagManagerOpen((v) => !v)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-[var(--muted)]/30"
>
<span className="flex items-center gap-2 text-[13px] font-medium text-[var(--foreground)]">
<TagIcon
size={14}
strokeWidth={1.7}
className="text-[var(--muted-foreground)]"
/>
{t("Manage Tags")}
<span className="rounded-full bg-[var(--muted)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--muted-foreground)]">
{tagVocab.length}
</span>
</span>
<ChevronDown
size={14}
className={`text-[var(--muted-foreground)] transition-transform duration-200 ${
tagManagerOpen ? "rotate-180" : ""
}`}
/>
</button>
{tagManagerOpen && (
<div className="border-t border-[var(--border)]/70 px-4 pb-4 pt-3">
<p className="mb-3 text-[11.5px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Tags help you group skills by purpose. Renaming a tag updates every skill using it.",
)}
</p>
<div className="space-y-1.5">
{tagVocab.map((tag) => (
<div
key={tag}
className="flex items-center justify-between gap-2 rounded-lg bg-[var(--muted)]/40 px-3 py-1.5"
>
{renamingTag?.original === tag ? (
<input
autoFocus
value={renamingTag.value}
onChange={(e) =>
setRenamingTag({
original: tag,
value: e.target.value,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") void handleRenameTag();
if (e.key === "Escape") setRenamingTag(null);
}}
onBlur={() => void handleRenameTag()}
className="flex-1 rounded border border-[var(--border)] bg-[var(--background)] px-2 py-0.5 text-[12px] text-[var(--foreground)] outline-none"
/>
) : (
<span className="inline-flex items-center gap-1.5 text-[12px] text-[var(--foreground)]">
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-[var(--foreground)]/40" />
{tag}
<span className="text-[var(--muted-foreground)]">
({tagCounts.get(tag) ?? 0})
</span>
</span>
)}
<div className="flex items-center gap-1">
<button
onClick={() =>
setRenamingTag({ original: tag, value: tag })
}
className="rounded p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
title={t("Rename")}
>
<Pencil size={12} />
</button>
<button
onClick={() => void handleDeleteTag(tag)}
className="rounded p-1 text-[var(--muted-foreground)] transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
title={t("Delete")}
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
{!tagVocab.length && (
<p className="py-2 text-center text-[12px] text-[var(--muted-foreground)]">
{t("No tags yet.")}
</p>
)}
</div>
<div className="mt-3 flex items-center gap-1.5">
<input
value={newTagDraft}
onChange={(e) => setNewTagDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void handleCreateTag()}
placeholder={t("New tag name...")}
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-1.5 text-[12px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
/>
<button
onClick={() => void handleCreateTag()}
disabled={!newTagDraft.trim()}
className="inline-flex items-center gap-1 rounded-lg bg-[var(--foreground)] px-3 py-1.5 text-[12px] font-medium text-[var(--background)] transition-opacity hover:opacity-90 disabled:opacity-30"
>
<Plus size={12} strokeWidth={2.2} />
{t("Add")}
</button>
</div>
</div>
)}
</section>
{/* Tag filter bar */}
<div className="-mt-2 flex flex-wrap items-center gap-1.5">
<TagFilterChip
label={t("All")}
count={skills.length}
active={filterTag === "all"}
onClick={() => setFilterTag("all")}
/>
{tagVocab.map((tag) => (
<TagFilterChip
key={tag}
label={tag}
count={tagCounts.get(tag) ?? 0}
active={filterTag === tag}
onClick={() => setFilterTag(tag)}
/>
))}
{untaggedCount > 0 && (
<TagFilterChip
label={t("Untagged")}
count={untaggedCount}
active={filterTag === "untagged"}
onClick={() => setFilterTag("untagged")}
muted
/>
)}
</div>
{errorMsg && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
{errorMsg}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-4 w-4 animate-spin text-[var(--muted-foreground)]" />
</div>
) : skills.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--card)]/40 px-6 py-14 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--muted)]/60 text-[var(--muted-foreground)]">
<Sparkles size={18} />
</div>
<p className="text-[14px] font-medium text-[var(--foreground)]">
{t("No skills yet")}
</p>
<p className="mx-auto mt-1 max-w-sm text-[12.5px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"Create a skill to define reusable guidance (e.g. a patient tutor, a rigorous research assistant).",
)}
</p>
<button
onClick={openCreate}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-[var(--primary)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--primary-foreground)] shadow-sm transition-opacity hover:opacity-90"
>
<Plus size={13} strokeWidth={2} />
{t("Create your first skill")}
</button>
</div>
) : filteredSkills.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border)] px-6 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
{t("No skills match this filter.")}
</div>
) : (
<ul className="grid gap-3 md:grid-cols-2">
{filteredSkills.map((skill) => {
const readOnly = Boolean(skill.read_only);
const sourceBadge =
skill.source === "builtin"
? t("Built-in")
: skill.source === "admin"
? t("Preset")
: null;
return (
<li
key={skill.name}
role="button"
tabIndex={0}
onClick={() => void openView(skill)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void openView(skill);
}
}}
title={t("View skill")}
className="group relative flex cursor-pointer flex-col rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 shadow-sm transition-all hover:border-[var(--foreground)]/30 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]/40"
>
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-[var(--border)]/60 bg-[var(--background)] text-[var(--muted-foreground)]">
<Wand2 size={13} strokeWidth={1.6} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-[14px] font-semibold tracking-tight text-[var(--foreground)]">
{skill.name}
</span>
{sourceBadge ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{readOnly ? <Lock size={9} /> : null}
{sourceBadge}
</span>
) : null}
</div>
{skill.description ? (
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-[var(--muted-foreground)]">
{skill.description}
</p>
) : (
<p className="mt-0.5 text-[12px] italic text-[var(--muted-foreground)]/60">
{t("No description.")}
</p>
)}
</div>
</div>
{readOnly ? (
<span className="shrink-0 text-[var(--muted-foreground)] opacity-0 transition-opacity group-hover:opacity-100">
<Eye size={13} />
</span>
) : (
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
void openEdit(skill.name);
}}
className="rounded-md p-1.5 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
title={t("Edit")}
>
<Pencil size={13} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
void handleDelete(skill.name);
}}
disabled={deleting === skill.name}
className="rounded-md p-1.5 text-[var(--muted-foreground)] transition-colors hover:bg-red-50 hover:text-red-600 disabled:opacity-50 dark:hover:bg-red-950/30"
title={t("Delete")}
>
{deleting === skill.name ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Trash2 size={13} />
)}
</button>
</div>
)}
</div>
{skill.tags && skill.tags.length > 0 ? (
<div className="mt-auto flex flex-wrap gap-1 pt-2">
{skill.tags.map((tag) => (
<button
key={tag}
onClick={(e) => {
e.stopPropagation();
setFilterTag(tag);
}}
className="inline-flex items-center gap-1 rounded-full border border-[var(--border)]/60 bg-[var(--muted)]/40 px-2 py-0.5 text-[10.5px] font-medium text-[var(--muted-foreground)] transition-colors hover:border-[var(--foreground)]/30 hover:text-[var(--foreground)]"
>
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-[var(--foreground)]/40" />
{tag}
</button>
))}
</div>
) : (
<div className="mt-auto pt-2">
<span className="inline-flex items-center gap-1 rounded-full border border-dashed border-[var(--border)] px-2 py-0.5 text-[10.5px] text-[var(--muted-foreground)]/70">
<TagIcon size={10} />
{t("Untagged")}
</span>
</div>
)}
</li>
);
})}
</ul>
)}
{/* Viewer modal — read-only content view, available for every skill */}
{viewer && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--overlay)] p-4"
role="dialog"
aria-modal="true"
onClick={() => setViewer(null)}
>
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--border)] px-5 py-3">
<div className="flex min-w-0 items-center gap-2">
<Wand2
size={14}
className="shrink-0 text-[var(--muted-foreground)]"
/>
<h3 className="truncate text-[14px] font-semibold text-[var(--foreground)]">
{viewer.name}
</h3>
{viewer.readOnly ? (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
<Lock size={9} />
{viewer.source === "admin" ? t("Preset") : t("Built-in")}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{viewer.readOnly ? null : (
<button
onClick={() => {
const name = viewer.name;
setViewer(null);
void openEdit(name);
}}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[12px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
title={t("Edit")}
>
<Pencil size={12} />
{t("Edit")}
</button>
)}
<button
onClick={() => setViewer(null)}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{viewer.description ? (
<p className="mb-3 text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{viewer.description}
</p>
) : null}
{viewer.loading ? (
<div className="flex items-center justify-center py-10 text-[var(--muted-foreground)]">
<Loader2 size={18} className="animate-spin" />
</div>
) : viewer.error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12.5px] text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
{viewer.error}
</div>
) : (
<div className="prose-skill text-[13.5px] leading-relaxed text-[var(--foreground)]">
<SkillMarkdown content={stripFrontmatter(viewer.content)} />
</div>
)}
</div>
</div>
</div>
)}
{/* Editor modal */}
{editor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--overlay)] p-4"
role="dialog"
aria-modal="true"
>
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--background)] shadow-2xl">
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-3">
<div className="flex items-center gap-2">
<Wand2 size={14} className="text-[var(--muted-foreground)]" />
<h3 className="text-[14px] font-semibold text-[var(--foreground)]">
{editor.mode === "create" ? t("New skill") : t("Edit skill")}
</h3>
</div>
<button
onClick={() => setEditor(null)}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X size={14} />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Name")}
</label>
<input
value={editor.name}
onChange={(e) =>
setEditor({
...editor,
name: slugifySkillName(e.target.value),
})
}
placeholder={t("e.g. socratic-math-mentor")}
className={`w-full rounded-lg border bg-[var(--background)] px-3 py-2 text-[13px] outline-none transition-colors focus:border-[var(--foreground)]/25 ${
editorNameInvalid
? "border-red-400 dark:border-red-600"
: "border-[var(--border)]"
}`}
/>
<p
className={`mt-1 text-[11px] transition-colors ${
editorNameInvalid
? "text-red-500 dark:text-red-400"
: "text-[var(--muted-foreground)]/70"
}`}
>
{t("Lowercase letters, digits, and hyphens only.")}
</p>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Description")}
</label>
<input
value={editor.description}
onChange={(e) =>
setEditor({ ...editor, description: e.target.value })
}
placeholder={t("Short summary used by Auto mode")}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-[13px] outline-none transition-colors focus:border-[var(--foreground)]/25"
/>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Tags")}
</label>
<div className="flex flex-wrap gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--background)] p-2">
{availableTagsForEditor.length === 0 && (
<span className="px-1 text-[11px] italic text-[var(--muted-foreground)]/70">
{t("No tags yet — add one below.")}
</span>
)}
{availableTagsForEditor.map((tag) => {
const active = editor.tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => toggleEditorTag(tag)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] transition-colors ${
active
? "bg-[var(--foreground)] text-[var(--background)]"
: "border border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--foreground)]/30 hover:text-[var(--foreground)]"
}`}
>
{active ? (
<Check size={10} strokeWidth={2.4} />
) : (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-current opacity-50" />
)}
{tag}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-1.5">
<input
value={editorTagDraft}
onChange={(e) => setEditorTagDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleCreateEditorTag();
}
}}
placeholder={t("Add a tag...")}
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-1.5 text-[12px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
/>
<button
type="button"
onClick={() => void handleCreateEditorTag()}
disabled={!editorTagDraft.trim()}
className="inline-flex items-center gap-1 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 px-2.5 py-1.5 text-[12px] font-medium text-[var(--foreground)] transition-colors hover:bg-[var(--muted)] disabled:opacity-40"
>
<Plus size={12} strokeWidth={2.2} />
{t("Add")}
</button>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("Markdown body")}
</label>
<textarea
value={editor.content}
onChange={(e) =>
setEditor({ ...editor, content: e.target.value })
}
rows={14}
spellCheck={false}
className="w-full resize-y rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 font-mono text-[12px] leading-relaxed outline-none transition-colors focus:border-[var(--foreground)]/25"
/>
<p className="mt-1 text-[11px] text-[var(--muted-foreground)]/70">
{t(
"YAML frontmatter is optional and is auto-managed for name, description, and tags.",
)}
</p>
</div>
{editor.error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
{editor.error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border)] px-5 py-3">
<button
onClick={() => setEditor(null)}
className="rounded-md px-3 py-1.5 text-[12px] font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{t("Cancel")}
</button>
<button
onClick={() => void handleSave()}
disabled={editor.saving}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--foreground)] px-3.5 py-1.5 text-[12px] font-medium text-[var(--background)] transition-opacity hover:opacity-90 disabled:opacity-50"
>
{editor.saving && (
<Loader2 size={12} className="animate-spin" />
)}
{t("Save")}
</button>
</div>
</div>
</div>
)}
{importOpen && (
<EduHubImportModal
onClose={() => setImportOpen(false)}
onInstalled={() => void load()}
installedNames={new Set(skills.map((s) => s.name))}
/>
)}
</div>
);
}
interface TagFilterChipProps {
label: string;
count: number;
active: boolean;
onClick: () => void;
muted?: boolean;
}
function TagFilterChip({
label,
count,
active,
onClick,
muted,
}: TagFilterChipProps) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors ${
active
? "border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)]"
: muted
? "border-dashed border-[var(--border)] text-[var(--muted-foreground)]/80 hover:text-[var(--foreground)]"
: "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--foreground)]/30 hover:text-[var(--foreground)]"
}`}
>
{label}
<span
className={`tabular-nums ${
active ? "opacity-80" : "text-[var(--muted-foreground)]/70"
}`}
>
{count}
</span>
</button>
);
}