feat: Skills.sh marketplace integration + navigation restructure
Skills.sh Marketplace: - Add marketplace browser with search, detail panel, and install/uninstall - Search API proxying skills.sh with lock file integration for installed state - Install/remove APIs using `npx skills` CLI with SSE streaming progress - SKILL.md content fetching via GitHub Git Trees API with in-memory caching - MarketplaceSkillCard, MarketplaceSkillDetail, MarketplaceBrowser components - InstallProgressDialog with real-time SSE log output - SkillsManager gains "My Skills" / "Marketplace" segmented control - Type definitions: MarketplaceSkill, SkillLockFile, SkillLockEntry Navigation restructure: - Split Extensions page into standalone /skills and /mcp top-level pages - NavRail: replace single Extensions icon with separate Skills (ZapIcon) and MCP (Plug01Icon) entries - /extensions page now redirects to /skills or /mcp based on ?tab param - /plugins redirects updated to point to new routes - Remove redundant source badges from SkillListItem (section headers suffice) Other: - Add WeChat user group QR code to README.md and README_CN.md - i18n: add 19 marketplace-related keys in en.ts and zh.ts - Bump version to 0.18.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
495319fe1f
commit
c4d03c46e6
25 files changed
+1065
-83
No files matched your search
@@ -7,7 +7,8 @@ import { useCallback, useSyncExternalStore } from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Message02Icon,
|
||||
GridIcon,
|
||||
ZapIcon,
|
||||
Plug01Icon,
|
||||
Image01Icon,
|
||||
Settings02Icon,
|
||||
Moon02Icon,
|
||||
@@ -34,7 +35,8 @@ interface NavRailProps {
|
||||
|
||||
const navItems = [
|
||||
{ href: "/chat", label: "Chats", icon: Message02Icon },
|
||||
{ href: "/extensions", label: "Extensions", icon: GridIcon },
|
||||
{ href: "/skills", label: "Skills", icon: ZapIcon },
|
||||
{ href: "/mcp", label: "MCP", icon: Plug01Icon },
|
||||
{ href: "/gallery", label: "Gallery", icon: Image01Icon },
|
||||
{ href: "/settings", label: "Settings", icon: Settings02Icon },
|
||||
] as const;
|
||||
@@ -46,7 +48,8 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
const { t } = useTranslation();
|
||||
const navLabelKeys: Record<string, TranslationKey> = {
|
||||
'Chats': 'nav.chats',
|
||||
'Extensions': 'nav.extensions',
|
||||
'Skills': 'extensions.skills',
|
||||
'MCP': 'extensions.mcpServers',
|
||||
'Gallery': 'gallery.title',
|
||||
'Settings': 'nav.settings',
|
||||
};
|
||||
@@ -62,9 +65,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi
|
||||
const isActive =
|
||||
item.href === "/chat"
|
||||
? pathname === "/chat" || pathname.startsWith("/chat/")
|
||||
: item.href === "/extensions"
|
||||
? pathname.startsWith("/extensions")
|
||||
: pathname === item.href || pathname.startsWith(item.href + "?");
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/") || pathname.startsWith(item.href + "?");
|
||||
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Loading02Icon, CheckmarkCircle02Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
|
||||
interface InstallProgressDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
action: "install" | "uninstall";
|
||||
source: string;
|
||||
skillName: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
type Phase = "running" | "success" | "error";
|
||||
|
||||
export function InstallProgressDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
action,
|
||||
source,
|
||||
skillName,
|
||||
onComplete,
|
||||
}: InstallProgressDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<Phase>("running");
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const startProcess = useCallback(async () => {
|
||||
setPhase("running");
|
||||
setLogs([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
action === "install"
|
||||
? "/api/skills/marketplace/install"
|
||||
: "/api/skills/marketplace/remove";
|
||||
|
||||
const body =
|
||||
action === "install"
|
||||
? { source, global: true }
|
||||
: { skill: skillName, global: true };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, `HTTP ${res.status}: ${res.statusText}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6);
|
||||
let data: string;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = raw;
|
||||
}
|
||||
|
||||
if (currentEvent === "output") {
|
||||
setLogs((prev) => [...prev, data]);
|
||||
} else if (currentEvent === "done") {
|
||||
setPhase("success");
|
||||
} else if (currentEvent === "error") {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, `Error: ${data}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setPhase("error");
|
||||
setLogs((prev) => [...prev, (err as Error).message]);
|
||||
}
|
||||
}
|
||||
}, [action, source, skillName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startProcess();
|
||||
}
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [open, startProcess]);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
const handleClose = () => {
|
||||
abortRef.current?.abort();
|
||||
if (phase === "success") {
|
||||
onComplete();
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{phase === "running" && (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-blue-500" />
|
||||
)}
|
||||
{phase === "success" && (
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{phase === "error" && (
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
{phase === "running"
|
||||
? t('skills.installing')
|
||||
: phase === "success"
|
||||
? t('skills.installSuccess')
|
||||
: t('skills.installFailed')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-muted/50 rounded-md p-3 max-h-64 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||
{logs.length === 0 && phase === "running" && (
|
||||
<span className="text-muted-foreground">{t('skills.installing')}</span>
|
||||
)}
|
||||
{logs.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>
|
||||
{phase === "running" ? t('common.cancel') : t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Search01Icon, Loading02Icon, Store01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { MarketplaceSkillCard } from "./MarketplaceSkillCard";
|
||||
import { MarketplaceSkillDetail } from "./MarketplaceSkillDetail";
|
||||
import type { MarketplaceSkill } from "@/types";
|
||||
|
||||
interface MarketplaceBrowserProps {
|
||||
onInstalled: () => void;
|
||||
}
|
||||
|
||||
export function MarketplaceBrowser({ onInstalled }: MarketplaceBrowserProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkill[]>([]);
|
||||
const [selected, setSelected] = useState<MarketplaceSkill | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const doSearch = useCallback(async (query: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set("q", query);
|
||||
params.set("limit", "20");
|
||||
const res = await fetch(`/api/skills/marketplace/search?${params}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setResults(data.skills || []);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load — fetch popular skills
|
||||
useEffect(() => {
|
||||
doSearch("");
|
||||
}, [doSearch]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(search);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search, doSearch]);
|
||||
|
||||
const handleInstallComplete = useCallback(() => {
|
||||
// Refresh results to update installed status
|
||||
doSearch(search);
|
||||
onInstalled();
|
||||
}, [search, doSearch, onInstalled]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left: search + results */}
|
||||
<div className="w-64 shrink-0 flex flex-col border border-border rounded-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('skills.marketplaceSearch')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="p-1">
|
||||
{loading && results.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground px-3">
|
||||
<p className="text-xs text-center text-red-500">{t('skills.marketplaceError')}</p>
|
||||
<p className="text-[10px] text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && results.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Store01Icon} className="h-8 w-8 opacity-40" />
|
||||
<p className="text-xs">{t('skills.searchNoResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
{results.map((skill) => (
|
||||
<MarketplaceSkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
selected={selected?.id === skill.id}
|
||||
onSelect={() => setSelected(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: detail */}
|
||||
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
|
||||
{selected ? (
|
||||
<MarketplaceSkillDetail
|
||||
key={selected.id}
|
||||
skill={selected}
|
||||
onInstallComplete={handleInstallComplete}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<HugeiconsIcon icon={Store01Icon} className="h-12 w-12 opacity-30" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">{t('skills.marketplaceHint')}</p>
|
||||
<p className="text-xs">{t('skills.marketplaceHintDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ZapIcon, Download04Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { MarketplaceSkill } from "@/types";
|
||||
|
||||
interface MarketplaceSkillCardProps {
|
||||
skill: MarketplaceSkill;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export function MarketplaceSkillCard({
|
||||
skill,
|
||||
selected,
|
||||
onSelect,
|
||||
}: MarketplaceSkillCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-3 py-2 cursor-pointer transition-colors",
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{skill.name}</span>
|
||||
{skill.isInstalled ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 border-green-500/40 text-green-600 dark:text-green-400"
|
||||
>
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-2.5 w-2.5 mr-0.5" />
|
||||
{t('skills.installed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{skill.source}</span>
|
||||
{skill.installs > 0 && (
|
||||
<span className="flex items-center gap-0.5 shrink-0">
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-3 w-3" />
|
||||
{skill.installs.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
Download04Icon,
|
||||
Delete02Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
LinkSquare01Icon,
|
||||
ZapIcon,
|
||||
Loading02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { InstallProgressDialog } from "./InstallProgressDialog";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { MarketplaceSkill } from "@/types";
|
||||
|
||||
interface MarketplaceSkillDetailProps {
|
||||
skill: MarketplaceSkill;
|
||||
onInstallComplete: () => void;
|
||||
}
|
||||
|
||||
export function MarketplaceSkillDetail({
|
||||
skill,
|
||||
onInstallComplete,
|
||||
}: MarketplaceSkillDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [progressAction, setProgressAction] = useState<"install" | "uninstall">("install");
|
||||
const [readme, setReadme] = useState<string | null>(null);
|
||||
const [readmeLoading, setReadmeLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReadme(null);
|
||||
setReadmeLoading(true);
|
||||
|
||||
const fetchReadme = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
source: skill.source,
|
||||
skillId: skill.skillId,
|
||||
});
|
||||
const res = await fetch(`/api/skills/marketplace/readme?${params}`);
|
||||
if (!cancelled && res.ok) {
|
||||
const data = await res.json();
|
||||
setReadme(data.content || null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) setReadmeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReadme();
|
||||
return () => { cancelled = true; };
|
||||
}, [skill.source, skill.skillId]);
|
||||
|
||||
const handleInstall = () => {
|
||||
setProgressAction("install");
|
||||
setShowProgress(true);
|
||||
};
|
||||
|
||||
const handleUninstall = () => {
|
||||
setProgressAction("uninstall");
|
||||
setShowProgress(true);
|
||||
};
|
||||
|
||||
const githubUrl = skill.source.includes("/")
|
||||
? `https://github.com/${skill.source}`
|
||||
: null;
|
||||
|
||||
// Strip YAML front matter from readme for display
|
||||
const displayContent = readme
|
||||
? readme.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-6 py-4 shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/50 shrink-0">
|
||||
<HugeiconsIcon icon={ZapIcon} className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold truncate">{skill.name}</h3>
|
||||
{skill.isInstalled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 border-green-500/40 text-green-600 dark:text-green-400 shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-2.5 w-2.5 mr-0.5" />
|
||||
{t('skills.installed')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground truncate">{skill.source}</span>
|
||||
{githubUrl && (
|
||||
<a
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<HugeiconsIcon icon={LinkSquare01Icon} className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{skill.installs > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground shrink-0">
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-3 w-3" />
|
||||
{skill.installs.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{skill.isInstalled ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleUninstall}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
|
||||
{t('skills.uninstall')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1.5" onClick={handleInstall}>
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-3.5 w-3.5" />
|
||||
{t('skills.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — SKILL.md content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{readmeLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : displayContent ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none px-6 py-4">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{displayContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<p className="text-sm">{t('skills.noReadme')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InstallProgressDialog
|
||||
open={showProgress}
|
||||
onOpenChange={setShowProgress}
|
||||
action={progressAction}
|
||||
source={skill.source}
|
||||
skillName={skill.name}
|
||||
onComplete={onInstallComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,31 +68,7 @@ export function SkillListItem({
|
||||
>
|
||||
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">/{skill.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0",
|
||||
skill.source === "global"
|
||||
? "border-green-500/40 text-green-600 dark:text-green-400"
|
||||
: skill.source === "installed"
|
||||
? "border-orange-500/40 text-orange-600 dark:text-orange-400"
|
||||
: "border-indigo-500/40 text-indigo-600 dark:text-indigo-400"
|
||||
)}
|
||||
>
|
||||
{skill.source === "global" ? (
|
||||
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
|
||||
) : skill.source === "installed" ? (
|
||||
<HugeiconsIcon icon={Download04Icon} className="h-2.5 w-2.5 mr-0.5" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Plug01Icon} className="h-2.5 w-2.5 mr-0.5" />
|
||||
)}
|
||||
{skill.source === "installed" && skill.installedSource
|
||||
? `installed:${skill.installedSource}`
|
||||
: skill.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate block">/{skill.name}</span>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
@@ -10,9 +10,13 @@ import { PlusSignIcon, Search01Icon, ZapIcon, Loading02Icon } from "@hugeicons/c
|
||||
import { SkillListItem } from "./SkillListItem";
|
||||
import { SkillEditor } from "./SkillEditor";
|
||||
import { CreateSkillDialog } from "./CreateSkillDialog";
|
||||
import { MarketplaceBrowser } from "./MarketplaceBrowser";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SkillItem } from "./SkillListItem";
|
||||
|
||||
type ViewTab = "local" | "marketplace";
|
||||
|
||||
export function SkillsManager() {
|
||||
const { workingDirectory } = usePanel();
|
||||
const { t } = useTranslation();
|
||||
@@ -21,6 +25,7 @@ export function SkillsManager() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [viewTab, setViewTab] = useState<ViewTab>("local");
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
try {
|
||||
@@ -150,14 +155,45 @@ export function SkillsManager() {
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h3 className="text-lg font-semibold flex-1">{t('extensions.skills')}</h3>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
{t('skills.newSkill')}
|
||||
</Button>
|
||||
<h3 className="text-lg font-semibold">{t('extensions.skills')}</h3>
|
||||
{/* Segmented control */}
|
||||
<div className="flex items-center bg-muted rounded-md p-0.5">
|
||||
<button
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-medium rounded transition-colors",
|
||||
viewTab === "local"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setViewTab("local")}
|
||||
>
|
||||
{t('skills.mySkills')}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs font-medium rounded transition-colors",
|
||||
viewTab === "marketplace"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setViewTab("marketplace")}
|
||||
>
|
||||
{t('skills.marketplace')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{viewTab === "local" && (
|
||||
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
{t('skills.newSkill')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
{viewTab === "marketplace" ? (
|
||||
<MarketplaceBrowser onInstalled={fetchSkills} />
|
||||
) : (
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left: skill list */}
|
||||
<div className="w-64 shrink-0 flex flex-col border border-border rounded-lg overflow-hidden">
|
||||
@@ -288,6 +324,7 @@ export function SkillsManager() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateSkillDialog
|
||||
open={showCreate}
|
||||
|
||||
Reference in new issue
Block a user