diff --git a/crates/desktop/src/lib/locales/en.ts b/crates/desktop/src/lib/locales/en.ts index cd5bb06f..77c6205d 100644 --- a/crates/desktop/src/lib/locales/en.ts +++ b/crates/desktop/src/lib/locales/en.ts @@ -772,6 +772,9 @@ export default { searchToFindSkills: "Search to find skills from skills.sh", poweredByVercel: "Powered by Vercel", dataFromSkillsSh: "Data from skills.sh", + copySkillsShLink: "Copy skills.sh link", + skillsShLinkCopied: "skills.sh link copied", + skillsShLinkCopyFailed: "Failed to copy skills.sh link", // MCP Detail connection: "Connection", diff --git a/crates/desktop/src/lib/locales/zh-Hans.ts b/crates/desktop/src/lib/locales/zh-Hans.ts index 3adfa9a8..aa6dbd9c 100644 --- a/crates/desktop/src/lib/locales/zh-Hans.ts +++ b/crates/desktop/src/lib/locales/zh-Hans.ts @@ -763,6 +763,9 @@ export default { searchToFindSkills: "搜索以从 skills.sh 查找技能", poweredByVercel: "由 Vercel 提供支持", dataFromSkillsSh: "数据来自 skills.sh", + copySkillsShLink: "复制 skills.sh 链接", + skillsShLinkCopied: "skills.sh 链接已复制", + skillsShLinkCopyFailed: "复制 skills.sh 链接失败", // Projects addProject: "添加项目", diff --git a/crates/desktop/src/lib/locales/zh-Hant.ts b/crates/desktop/src/lib/locales/zh-Hant.ts index 2b775d77..36bd1cee 100644 --- a/crates/desktop/src/lib/locales/zh-Hant.ts +++ b/crates/desktop/src/lib/locales/zh-Hant.ts @@ -761,6 +761,9 @@ export default { searchToFindSkills: "搜尋以從 skills.sh 尋找技能", poweredByVercel: "由 Vercel 提供支援", dataFromSkillsSh: "資料來自 skills.sh", + copySkillsShLink: "複製 skills.sh 連結", + skillsShLinkCopied: "skills.sh 連結已複製", + skillsShLinkCopyFailed: "複製 skills.sh 連結失敗", // Projects addProject: "新增專案", diff --git a/crates/desktop/src/pages/skills-sh/search.tsx b/crates/desktop/src/pages/skills-sh/search.tsx index 63f6d088..f13cfccb 100644 --- a/crates/desktop/src/pages/skills-sh/search.tsx +++ b/crates/desktop/src/pages/skills-sh/search.tsx @@ -1,6 +1,7 @@ import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; -import { Button, Spinner } from "@heroui/react"; +import { Button, Spinner, toast } from "@heroui/react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { useQueryState } from "nuqs"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -23,6 +24,21 @@ import { useSkillInstall } from "./hooks/use-skill-install"; const BATCH_SIZE = 20; const FETCH_SIZE = 100; const ROW_HEIGHT = 48; +const SKILLS_SH_BASE_URL = "https://www.skills.sh"; + +function splitUrlPath(path: string) { + return path.split("/").filter(Boolean); +} + +function normalizeSkillsShPathParts(parts: string[]) { + return parts[0] === "github" ? parts.slice(1) : parts; +} + +function formatSkillsShUrl(parts: string[]) { + const urlSegments = parts.map((part) => encodeURIComponent(part)); + + return `${SKILLS_SH_BASE_URL}/${urlSegments.join("/")}`; +} const tableComponents: TableComponents = { Table: ({ style, ...props }) => ( @@ -45,6 +61,38 @@ const tableComponents: TableComponents = { ), }; +function buildSkillsShUrl(skill: MarketSkill) { + const slugParts = normalizeSkillsShPathParts(splitUrlPath(skill.slug)); + if (slugParts.length > 1) { + return formatSkillsShUrl(slugParts); + } + + const sourceParts = splitUrlPath(skill.source); + const pathParts = (() => { + if (sourceParts.length === 0) { + return []; + } + + if (sourceParts[0] === "github") { + return sourceParts.slice(1); + } + + if (sourceParts[0] === "site") { + return sourceParts; + } + + if (sourceParts.length === 1 && sourceParts[0].includes(".")) { + return ["site", ...sourceParts]; + } + + return sourceParts; + })(); + const skillParts = + slugParts.length > 0 ? slugParts : splitUrlPath(skill.name); + + return formatSkillsShUrl([...pathParts, ...skillParts]); +} + export default function SkillsSearchPage() { const { t, i18n } = useTranslation(); const api = useApi(); @@ -103,6 +151,21 @@ export default function SkillsSearchPage() { const hasMore = visibleCount < searchResults.length; + const handleCopySkillsShUrl = useCallback( + async (skill: MarketSkill) => { + const skillsShUrl = buildSkillsShUrl(skill); + + try { + await writeText(skillsShUrl); + toast.success(t("skillsShLinkCopied")); + } catch (error) { + console.error("Failed to copy skills.sh URL:", error); + toast.danger(t("skillsShLinkCopyFailed")); + } + }, + [t], + ); + const handleEndReached = useCallback(() => { if (hasMore && !isFetching) { setVisibleCount((c) => @@ -170,38 +233,49 @@ export default function SkillsSearchPage() { fixedItemHeight={ROW_HEIGHT} style={{ height: "100%" }} components={tableComponents} - itemContent={(_index, skill) => ( - <> - - - {skill.name} - - - - - {compactFormatter.format( - skill.installs, - )} - - - - - {skill.source} - - - - - - - )} + itemContent={(_index, skill) => { + return ( + <> + + + {skill.name} + + + + + {compactFormatter.format( + skill.installs, + )} + + + + + + + + + + ); + }} >