"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ArrowLeft, CheckCircle2, Download, ExternalLink, Loader2, RefreshCw, Search, Star, Store, Wand2, X, } from "lucide-react"; import { fetchHubCatalog, fetchHubSkillDetail, installSkillFromHub, type HubSkillDetail, type HubSkillListing, } from "@/lib/skills-api"; // Where the EduHub site lives, for "view on EduHub" links. The catalog // response carries the authoritative origin; this only backstops a failed load. const EDUHUB_FALLBACK = "https://eduhub.deeptutor.info"; // Lazy-load the markdown renderer so the heavier deps only ship when a user // opens a skill's detail view (matches the Skills viewer in SkillsSection). const SkillMarkdown = dynamic( () => import("@/components/common/SimpleMarkdownRenderer"), { ssr: false }, ); /** Drop the YAML frontmatter block so the detail view 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; } type InstallState = | { kind: "installing" } | { kind: "done"; verdict: string } | { kind: "error"; message: string }; export default function EduHubImportModal({ onClose, onInstalled, installedNames, }: { onClose: () => void; onInstalled: () => void; /** Names of skills already installed locally — shown as "已导入". */ installedNames?: Set; }) { const { i18n } = useTranslation(); const zh = i18n.language?.toLowerCase().startsWith("zh"); const tr = useCallback((cn: string, en: string) => (zh ? cn : en), [zh]); const [skills, setSkills] = useState(null); const [webUrl, setWebUrl] = useState(EDUHUB_FALLBACK); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); const [installState, setInstallState] = useState< Record >({}); // Slugs imported during this session, merged with the names passed from the // Skills list, so freshly-downloaded skills flip to "已导入" without a reload. const [installedLocal, setInstalledLocal] = useState>( () => new Set(installedNames ?? []), ); const loadCatalog = useCallback(async () => { setLoading(true); setError(null); try { const catalog = await fetchHubCatalog({ limit: 100 }); setSkills(catalog.skills); if (catalog.webUrl) setWebUrl(catalog.webUrl); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, []); useEffect(() => { void loadCatalog(); }, [loadCatalog]); // Escape closes (the detail view first, then the modal). useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key !== "Escape") return; if (selected) setSelected(null); else onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose, selected]); const filtered = useMemo(() => { if (!skills) return []; const q = query.trim().toLowerCase(); if (!q) return skills; return skills.filter( (s) => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q) || s.summary.toLowerCase().includes(q), ); }, [skills, query]); const openDetail = useCallback(async (skill: HubSkillListing) => { setSelected(skill); setDetail(null); setDetailError(null); setDetailLoading(true); try { setDetail(await fetchHubSkillDetail(skill.slug)); } catch (err) { setDetailError(err instanceof Error ? err.message : String(err)); } finally { setDetailLoading(false); } }, []); const install = useCallback( async (slug: string, opts?: { force?: boolean }) => { setInstallState((s) => ({ ...s, [slug]: { kind: "installing" } })); try { // The `eduhub:` hub is hardcoded so the install can never be redirected // to another registry, and the slug comes from our own proxied catalog. const result = await installSkillFromHub(`eduhub:${slug}`, { force: opts?.force, }); setInstallState((s) => ({ ...s, [slug]: { kind: "done", verdict: result.verdict.status }, })); setInstalledLocal((prev) => new Set(prev).add(slug)); onInstalled(); } catch (err) { setInstallState((s) => ({ ...s, [slug]: { kind: "error", message: err instanceof Error ? err.message : String(err), }, })); } }, [onInstalled], ); return (
e.stopPropagation()} > {/* Header */}

{tr("从 EduHub 导入技能", "Import skills from EduHub")}

{selected ? ( setSelected(null)} onInstall={install} /> ) : ( )}
); } // ── list view ────────────────────────────────────────────────────────────── function ListView({ skills, total, loading, error, query, installState, installedLocal, tr, onQueryChange, onRetry, onOpen, onInstall, }: { skills: HubSkillListing[]; total: number; loading: boolean; error: string | null; query: string; installState: Record; installedLocal: Set; tr: (cn: string, en: string) => string; onQueryChange: (q: string) => void; onRetry: () => void; onOpen: (skill: HubSkillListing) => void; onInstall: (slug: string, opts?: { force?: boolean }) => void; }) { return ( <> {/* Search */}
onQueryChange(e.target.value)} placeholder={tr("搜索技能名称或描述…", "Search skills…")} className="w-full rounded-lg border border-[var(--border)] bg-[var(--card)] py-2 pl-9 pr-3 text-[13px] text-[var(--foreground)] outline-none transition-colors placeholder:text-[var(--muted-foreground)]/70 focus:border-[var(--foreground)]/30" />
{loading ? (
) : error ? (

{tr("无法连接 EduHub。", "Couldn't reach EduHub.")}

{error}

) : skills.length === 0 ? (
{total === 0 ? tr("EduHub 上暂时还没有技能。", "No skills on EduHub yet.") : tr("没有匹配的技能。", "No skills match your search.")}
) : (
    {skills.map((skill) => ( onOpen(skill)} onInstall={onInstall} /> ))}
)}
); } function SkillCard({ skill, state, installed, tr, onOpen, onInstall, }: { skill: HubSkillListing; state?: InstallState; installed: boolean; tr: (cn: string, en: string) => string; onOpen: () => void; onInstall: (slug: string, opts?: { force?: boolean }) => void; }) { return (
  • { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }} title={tr("查看详情", "View details")} 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" >
    {skill.name} {installed ? ( {tr("已导入", "Installed")} ) : null}
    {skill.summary ? (

    {skill.summary}

    ) : (

    {tr("暂无描述。", "No description.")}

    )}
    {skill.downloads} {skill.stars} {skill.owner ? ( · {skill.owner} ) : null}
    onInstall(skill.slug, { force })} />
    {state?.kind === "error" ? (

    {state.message}

    ) : null}
  • ); } // ── detail view ────────────────────────────────────────────────────────────── function DetailView({ skill, detail, loading, error, installState, installed, webUrl, tr, onBack, onInstall, }: { skill: HubSkillListing; detail: HubSkillDetail | null; loading: boolean; error: string | null; installState?: InstallState; installed: boolean; webUrl: string; tr: (cn: string, en: string) => string; onBack: () => void; onInstall: (slug: string, opts?: { force?: boolean }) => void; }) { const version = detail?.version || skill.version; return ( <>
    {tr("在 EduHub 查看", "View on EduHub")} onInstall(skill.slug, { force })} />

    {skill.name}

    {version ? ( v{version} ) : null}
    {skill.downloads} {tr("次下载", "downloads")} {skill.stars} {skill.owner ? · {skill.owner} : null}
    {detail?.tags && detail.tags.length > 0 ? (
    {detail.tags.map((tag) => ( {tag} ))}
    ) : null} {installState?.kind === "error" ? (
    {tr("导入失败:", "Import failed: ")} {installState.message}
    ) : null} {loading ? (
    ) : error ? (
    {error}
    ) : detail ? (
    ) : null}
    ); } // ── shared install button ─────────────────────────────────────────────────── function InstallButton({ state, installed, tr, onClick, }: { state?: InstallState; installed: boolean; tr: (cn: string, en: string) => string; onClick: (force: boolean) => void; }) { if (state?.kind === "installing") { return ( {tr("下载中…", "Downloading…")} ); } const label = state?.kind === "error" ? tr("重试", "Retry") : installed ? tr("重新下载", "Re-download") : tr("下载", "Download"); const Icon = installed ? RefreshCw : Download; return ( ); }