Compare commits

...

5 Commits

Author SHA1 Message Date
ONLY-yours 8e96924774 fix: merge canayr 2026-04-01 17:27:58 +08:00
ONLY-yours 4b9ddc4fc2 ♻️ refactor: align skill collection types with API schema
- Update SkillCollectionItem type: coverUrl → cover, description → summary
- Add pagination fields to SkillCollectionDetail: currentPage, pageSize, totalCount, totalPages
- Update Hero, CollectionsSection, EditorsPick, MoreCollections components to use correct field names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-31 16:49:09 +08:00
ONLY-yours f0b16a5565 fix: merge canary 2026-03-31 15:58:33 +08:00
ONLY-yours 97987229a8 feat: skills collection list & detail connect pages init 2026-03-27 15:45:41 +08:00
ONLY-yours 4aa6dcd01a refactor: change the skills to new vision 2026-03-26 17:38:15 +08:00
29 changed files with 1679 additions and 38 deletions
+25
View File
@@ -547,6 +547,14 @@
"skills.categories.transportation.name": "Transportation",
"skills.categories.web-frontend-development.description": "Web development, frontend frameworks, and UI tooling skills",
"skills.categories.web-frontend-development.name": "Web & Frontend Development",
"skills.collection.editorCollection": "EDITOR'S COLLECTION",
"skills.collection.get": "Get",
"skills.collection.installAll": "Install all skills",
"skills.collection.installDesc": "Get all {{count}} skills installed at once. Your agent will walk you through each one.",
"skills.collection.installTitle": "Install the full collection",
"skills.collection.moreCollections": "More collections",
"skills.collection.skillCount": "{{count}} skills",
"skills.collection.skillsInCollection": "Skills in this collection",
"skills.details.nav.needHelp": "Need Help?",
"skills.details.nav.reportIssue": "Report Issue",
"skills.details.nav.viewSourceCode": "View Source Code",
@@ -590,11 +598,28 @@
"skills.details.versions.title": "Version History",
"skills.hero.guide.agent": "I am Agent",
"skills.hero.guide.human": "I am Human",
"skills.sections.collection": "Collection",
"skills.sections.collection1Desc": "A curated starter pack for developers building their first end-to-end coding agent workflow.",
"skills.sections.collection1Title": "Get started: your first autonomous coding agent",
"skills.sections.collection2Desc": "5 must-have skills to guard your agents against injection, leakage, and runaway permissions.",
"skills.sections.collection2Title": "Essential security skills for production agents",
"skills.sections.collections": "Editor's collections",
"skills.sections.editorsPick": "EDITOR'S PICK",
"skills.sections.editorsPickDesc": "Security-first vetting for every skill before it touches your agent. Checks permission scope, red flags, and injection risks automatically.",
"skills.sections.editorsPickTitle": "Build safer agents with skill-vetter",
"skills.sections.featured": "Featured",
"skills.sections.getSkill": "Get skill",
"skills.sections.learnMore": "Learn more",
"skills.sections.seeAll": "See all →",
"skills.sections.trending": "Trending this week",
"skills.sorts.createdAt": "Recently Published",
"skills.sorts.installCount": "Downloads",
"skills.sorts.name": "Name",
"skills.sorts.stars": "GitHub Stars",
"skills.sorts.updatedAt": "Recently Updated",
"skills.tabs.discover": "Discover",
"skills.tabs.new": "New",
"skills.tabs.trending": "Trending",
"tab.assistant": "Agent",
"tab.home": "Home",
"tab.model": "Model",
+25
View File
@@ -547,6 +547,14 @@
"skills.categories.transportation.name": "交通运输",
"skills.categories.web-frontend-development.description": "Web开发、前端框架和UI工具技能",
"skills.categories.web-frontend-development.name": "Web与前端开发",
"skills.collection.editorCollection": "编辑精选",
"skills.collection.get": "获取",
"skills.collection.installAll": "安装全部技能",
"skills.collection.installDesc": "一次性安装全部 {{count}} 个技能。你的智能体将逐一引导你完成配置。",
"skills.collection.installTitle": "安装完整合集",
"skills.collection.moreCollections": "更多合集",
"skills.collection.skillCount": "{{count}} 个技能",
"skills.collection.skillsInCollection": "合集中的技能",
"skills.details.nav.needHelp": "需要帮助?",
"skills.details.nav.reportIssue": "报告问题",
"skills.details.nav.viewSourceCode": "查看源码",
@@ -590,11 +598,28 @@
"skills.details.versions.title": "版本历史",
"skills.hero.guide.agent": "我是 Agent",
"skills.hero.guide.human": "我是人类",
"skills.sections.collection": "精选集",
"skills.sections.collection1Desc": "为开发者打造的入门套件,帮助你构建第一个端到端的编程智能体工作流。",
"skills.sections.collection1Title": "入门指南:你的第一个自主编程智能体",
"skills.sections.collection2Desc": "5 个必备技能,保护你的智能体免受注入、泄露和权限失控的威胁。",
"skills.sections.collection2Title": "生产环境智能体必备安全技能",
"skills.sections.collections": "编辑精选集",
"skills.sections.editorsPick": "编辑推荐",
"skills.sections.editorsPickDesc": "在技能触及你的智能体之前,进行安全优先的审查。自动检查权限范围、危险信号和注入风险。",
"skills.sections.editorsPickTitle": "使用 skill-vetter 构建更安全的智能体",
"skills.sections.featured": "精选",
"skills.sections.getSkill": "获取技能",
"skills.sections.learnMore": "了解更多",
"skills.sections.seeAll": "查看全部 →",
"skills.sections.trending": "本周热门",
"skills.sorts.createdAt": "最近发布",
"skills.sorts.installCount": "最多下载",
"skills.sorts.name": "名称",
"skills.sorts.stars": "GitHub 星标",
"skills.sorts.updatedAt": "最近更新",
"skills.tabs.discover": "发现",
"skills.tabs.new": "最新",
"skills.tabs.trending": "热门",
"tab.assistant": "助理",
"tab.home": "首页",
"tab.model": "模型",
+26
View File
@@ -94,3 +94,29 @@ export interface DiscoverSkillDetail extends MarketSkillDetail {
}
export type SkillCategoryItem = MarketSkillCategory;
// ============================== Skill Collections ==============================
export interface SkillCollectionItem {
cover?: string;
createdAt: string;
icon: string;
id: number;
itemCount: number;
position: number;
slug: string;
summary: string;
title: string;
updatedAt: string;
}
export interface SkillCollectionDetail extends SkillCollectionItem {
currentPage: number;
description?: string;
items: DiscoverSkillItem[];
pageSize: number;
totalCount: number;
totalPages: number;
}
export type SkillCollectionListResponse = SkillCollectionItem[];
+54
View File
@@ -1094,6 +1094,60 @@ export default {
'skills.hero.guide.human': 'I am Human',
'skills.sections.collection': 'Collection',
'skills.sections.collection1Desc':
'A curated starter pack for developers building their first end-to-end coding agent workflow.',
'skills.sections.collection1Title': 'Get started: your first autonomous coding agent',
'skills.sections.collection2Desc':
'5 must-have skills to guard your agents against injection, leakage, and runaway permissions.',
'skills.sections.collection2Title': 'Essential security skills for production agents',
'skills.sections.collections': "Editor's collections",
'skills.sections.editorsPick': "EDITOR'S PICK",
'skills.sections.editorsPickDesc':
'Security-first vetting for every skill before it touches your agent. Checks permission scope, red flags, and injection risks automatically.',
'skills.sections.editorsPickTitle': 'Build safer agents with skill-vetter',
'skills.sections.featured': 'Featured',
'skills.sections.getSkill': 'Get skill',
'skills.sections.learnMore': 'Learn more',
'skills.sections.seeAll': 'See all →',
'skills.sections.trending': 'Trending this week',
'skills.tabs.discover': 'Discover',
'skills.tabs.new': 'New',
'skills.tabs.trending': 'Trending',
'skills.collection.editorCollection': "EDITOR'S COLLECTION",
'skills.collection.get': 'Get',
'skills.collection.installAll': 'Install all skills',
'skills.collection.installDesc':
'Get all {{count}} skills installed at once. Your agent will walk you through each one.',
'skills.collection.installTitle': 'Install the full collection',
'skills.collection.moreCollections': 'More collections',
'skills.collection.skillCount': '{{count}} skills',
'skills.collection.skillsInCollection': 'Skills in this collection',
'skills.sorts.createdAt': 'Recently Published',
'skills.sorts.installCount': 'Downloads',
@@ -0,0 +1,138 @@
'use client';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Tag } from 'antd';
import { createStaticStyles, responsive } from 'antd-style';
import { ArrowLeftIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import PublishedTime from '@/components/PublishedTime';
const styles = createStaticStyles(({ css }) => ({
author: css`
font-size: 13px;
color: rgb(255 255 255 / 70%);
`,
backButton: css`
position: absolute;
inset-block-start: 16px;
inset-inline-start: 16px;
color: rgb(255 255 255 / 80%);
&:hover {
color: rgb(255 255 255);
background: rgb(255 255 255 / 15%);
}
`,
container: css`
position: relative;
overflow: hidden;
width: 100%;
min-height: 280px;
padding-block: 80px 40px;
padding-inline: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
${responsive.sm} {
min-height: 240px;
padding-block: 60px 24px;
padding-inline: 20px;
}
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.4;
background-position: center;
background-size: cover;
`,
description: css`
max-width: 500px;
margin: 0;
font-size: 15px;
line-height: 1.6;
color: rgb(255 255 255 / 85%);
${responsive.sm} {
font-size: 14px;
}
`,
tag: css`
border: none;
font-size: 10px;
font-weight: 700;
color: rgb(255 255 255 / 90%);
text-transform: uppercase;
letter-spacing: 1px;
background: rgb(255 255 255 / 20%);
`,
title: css`
margin: 0;
font-size: 36px;
font-weight: 700;
line-height: 1.2;
color: rgb(255 255 255);
${responsive.sm} {
font-size: 28px;
}
`,
}));
interface HeroProps {
cover?: string;
createdAt: string;
description?: string;
itemCount: number;
mobile?: boolean;
summary: string;
title: string;
updatedAt: string;
}
const Hero = memo<HeroProps>(
({ title, summary, description, itemCount, cover, createdAt, updatedAt, mobile }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const handleBack = useCallback(() => {
navigate('/community/skill');
}, [navigate]);
// Use description if available, otherwise fall back to summary
const displayDescription = description || summary;
return (
<div className={styles.container}>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<ActionIcon className={styles.backButton} icon={ArrowLeftIcon} onClick={handleBack} />
<Flexbox gap={16} style={{ maxWidth: 600, position: 'relative', zIndex: 1 }}>
<Tag className={styles.tag}>{t('skills.collection.editorCollection')}</Tag>
<h1 className={styles.title}>{title}</h1>
<p className={styles.description}>{displayDescription}</p>
<Flexbox horizontal align={'center'} className={styles.author} gap={8}>
<span>{t('skills.collection.skillCount', { count: itemCount })}</span>
{(updatedAt || createdAt) && (
<>
<span>·</span>
<PublishedTime date={updatedAt || createdAt} template={'MMM DD, YYYY'} />
</>
)}
</Flexbox>
</Flexbox>
</div>
);
},
);
export default Hero;
@@ -0,0 +1,103 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { Button } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { INBOX_SESSION_ID } from '@/const/session';
import { useIsDark } from '@/hooks/useIsDark';
import { useAgentStore } from '@/store/agent';
import { builtinAgentSelectors } from '@/store/agent/selectors';
import type { DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
button: css`
padding-inline: 24px;
border-radius: 24px;
`,
container: css`
width: 100%;
max-width: 720px;
margin-block: 0;
margin-inline: auto;
padding: 32px;
border-radius: 16px;
`,
container_dark: css`
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
`,
container_light: css`
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
`,
description: css`
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgb(255 255 255 / 65%);
`,
title: css`
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgb(255 255 255 / 95%);
`,
}));
interface InstallCTAProps {
collectionTitle: string;
skills: DiscoverSkillItem[];
}
const InstallCTA = memo<InstallCTAProps>(({ collectionTitle, skills }) => {
const { t } = useTranslation('discover');
const isDark = useIsDark();
const navigate = useNavigate();
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const handleInstallAll = useCallback(() => {
if (!skills || skills.length === 0) return;
// Build the skill identifiers list
const skillIdentifiers = skills.map((s) => `- ${s.identifier}`).join('\n');
// Build the installation message
const message = `Read https://lobehub.com/skills/skill.md and follow the instructions to setup LobeHub Skills Marketplace.
Then install every skill in the "${collectionTitle}" collection:
${skillIdentifiers}
After installation, read each installed SKILL.md and complete any required setup.`;
// Navigate to inbox with the message
const agentId = inboxAgentId || INBOX_SESSION_ID;
const encodedMessage = encodeURIComponent(message);
navigate(`/agent/${agentId}?message=${encodedMessage}`);
}, [skills, collectionTitle, inboxAgentId, navigate]);
const skillCount = skills?.length || 0;
return (
<Flexbox
align={'center'}
className={cx(styles.container, isDark ? styles.container_dark : styles.container_light)}
gap={16}
>
<h3 className={styles.title}>{t('skills.collection.installTitle')}</h3>
<p className={styles.description}>
{t('skills.collection.installDesc', { count: skillCount })}
</p>
<Button
className={styles.button}
size="large"
style={{ background: '#fff', color: '#000' }}
onClick={handleInstallAll}
>
{t('skills.collection.installAll')}
</Button>
</Flexbox>
);
});
export default InstallCTA;
@@ -0,0 +1,126 @@
'use client';
import { Block, Flexbox, Grid, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { type SkillCollectionItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
card: css`
position: relative;
overflow: hidden;
height: 160px;
padding: 20px;
border-radius: 12px;
`,
card_dark: css`
border: 1px solid rgb(255 255 255 / 8%);
background: rgb(255 255 255 / 4%);
`,
card_light: css`
border: 1px solid rgb(0 0 0 / 6%);
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.15;
background-position: center;
background-size: cover;
`,
desc: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
`,
desc_dark: css`
color: rgb(255 255 255 / 50%);
`,
desc_light: css`
color: rgb(0 0 0 / 50%);
`,
title: css`
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
interface CollectionCardProps extends SkillCollectionItem {}
const CollectionCard = memo<CollectionCardProps>(({ title, summary, slug, cover }) => {
const isDark = useIsDark();
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/collection', slug));
}, [navigate, slug]);
return (
<Block
clickable
className={cx(styles.card, isDark ? styles.card_dark : styles.card_light)}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<Flexbox gap={8} style={{ position: 'relative', zIndex: 1 }}>
<Text className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{title}
</Text>
<Text
className={cx(styles.desc, isDark ? styles.desc_dark : styles.desc_light)}
ellipsis={{ rows: 3 }}
>
{summary}
</Text>
</Flexbox>
</Block>
);
});
interface MoreCollectionsProps {
currentSlug: string;
}
const MoreCollections = memo<MoreCollectionsProps>(({ currentSlug }) => {
const { t } = useTranslation('discover');
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
if (isLoading || !data || data.length === 0) return null;
const otherCollections = data.filter((c) => c.slug !== currentSlug);
if (otherCollections.length === 0) return null;
return (
<Flexbox gap={16} style={{ maxWidth: 720, margin: '0 auto', width: '100%' }}>
<Title>{t('skills.collection.moreCollections')}</Title>
<Grid maxItemWidth={340} rows={2} width={'100%'}>
{otherCollections.slice(0, 4).map((item) => (
<CollectionCard key={item.slug} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default MoreCollections;
@@ -0,0 +1,150 @@
'use client';
import { Avatar, Flexbox, Icon, Text } from '@lobehub/ui';
import { Button, Divider } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { StarIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import type { DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css, cssVar }) => ({
author: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
container: css`
width: 100%;
max-width: 720px;
margin-block: 0;
margin-inline: auto;
`,
count: css`
font-size: 13px;
color: ${cssVar.colorTextDescription};
`,
description: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssVar.colorTextSecondary};
`,
getButton: css`
padding-inline: 16px;
border-radius: 20px;
`,
item: css`
padding-block: 16px;
border-radius: 8px;
&:hover {
background: ${cssVar.colorFillQuaternary};
}
`,
name: css`
font-size: 15px;
font-weight: 600;
color: ${cssVar.colorText};
`,
rating: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorWarning};
`,
sectionTitle: css`
margin: 0;
font-size: 18px;
font-weight: 600;
color: ${cssVar.colorText};
`,
}));
interface SkillItemProps extends DiscoverSkillItem {}
const SkillItem = memo<SkillItemProps>(
({ name, icon, identifier, author, description, ratingAvg }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/skill', identifier));
}, [navigate, identifier]);
const handleGet = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
navigate(urlJoin('/community/skill', identifier));
},
[navigate, identifier],
);
// Use ratingAvg, fallback to a default display if not available
const rating = ratingAvg || 0;
return (
<Flexbox
horizontal
align={'center'}
className={styles.item}
gap={16}
paddingInline={8}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
<Avatar avatar={icon || name} size={56} style={{ flexShrink: 0 }} />
<Flexbox flex={1} gap={4} style={{ minWidth: 0 }}>
<Text ellipsis className={styles.name}>
{name}
</Text>
<Text className={styles.author}>{author}</Text>
<Text ellipsis className={styles.description}>
{description}
</Text>
</Flexbox>
<Flexbox align={'center'} gap={8} style={{ flexShrink: 0 }}>
{rating > 0 && (
<Flexbox horizontal align={'center'} className={styles.rating} gap={4}>
<Icon fill={cssVar.colorWarning} fillOpacity={1} icon={StarIcon} size={14} />
{rating.toFixed(1)}
</Flexbox>
)}
<Button className={styles.getButton} shape="round" onClick={handleGet}>
{t('skills.collection.get')}
</Button>
</Flexbox>
</Flexbox>
);
},
);
interface SkillsListProps {
skills: DiscoverSkillItem[];
}
const SkillsList = memo<SkillsListProps>(({ skills }) => {
const { t } = useTranslation('discover');
if (!skills || skills.length === 0) return null;
return (
<Flexbox className={styles.container} gap={16}>
<Divider style={{ margin: 0 }} />
<Flexbox horizontal align={'center'} justify={'space-between'}>
<h2 className={styles.sectionTitle}>{t('skills.collection.skillsInCollection')}</h2>
<span className={styles.count}>
{t('skills.collection.skillCount', { count: skills.length })}
</span>
</Flexbox>
<Flexbox>
{skills.map((skill) => (
<SkillItem key={skill.identifier} {...skill} />
))}
</Flexbox>
</Flexbox>
);
});
export default SkillsList;
@@ -0,0 +1,53 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { useDiscoverStore } from '@/store/discover';
import NotFound from '../components/NotFound';
import Hero from './features/Hero';
import InstallCTA from './features/InstallCTA';
import MoreCollections from './features/MoreCollections';
import SkillsList from './features/SkillsList';
import Loading from './loading';
interface CollectionDetailPageProps {
mobile?: boolean;
}
const CollectionDetailPage = memo<CollectionDetailPageProps>(({ mobile }) => {
const params = useParams<{ slug: string }>();
const slug = params.slug ?? '';
const useFetchSkillCollectionDetail = useDiscoverStore((s) => s.useFetchSkillCollectionDetail);
const { data, isLoading } = useFetchSkillCollectionDetail({ slug });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<Flexbox data-testid="collection-detail-content" gap={40} width={'100%'}>
<Hero
cover={data.cover}
createdAt={data.createdAt}
description={data.description}
itemCount={data.itemCount}
mobile={mobile}
summary={data.summary}
title={data.title}
updatedAt={data.updatedAt}
/>
<SkillsList skills={data.items} />
{data.items.length > 0 && <InstallCTA collectionTitle={data.title} skills={data.items} />}
<MoreCollections currentSlug={slug} />
</Flexbox>
);
});
export const MobileCollectionPage = memo<{ mobile?: boolean }>(() => {
return <CollectionDetailPage mobile={true} />;
});
export default CollectionDetailPage;
@@ -0,0 +1 @@
export { DetailsLoading as default } from '../../components/ListLoading';
@@ -1,19 +1,10 @@
import { Flexbox } from '@lobehub/ui';
import { Outlet } from 'react-router-dom';
import CategoryContainer from '../../../components/CategoryContainer';
import Category from '../features/Category';
import { styles } from './style';
const Layout = () => {
return (
<Flexbox horizontal className={styles.mainContainer} gap={24} width={'100%'}>
<CategoryContainer>
<Category />
</CategoryContainer>
<Flexbox flex={1} gap={16}>
<Outlet />
</Flexbox>
<Flexbox gap={24} width={'100%'}>
<Outlet />
</Flexbox>
);
};
@@ -0,0 +1,122 @@
'use client';
import { Block, Flexbox, Grid, Text } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { type SkillCollectionItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
card: css`
position: relative;
overflow: hidden;
height: 180px;
padding: 20px;
border-radius: 12px;
`,
card_dark: css`
border: 1px solid rgb(255 255 255 / 8%);
background: rgb(255 255 255 / 4%);
`,
card_light: css`
border: 1px solid rgb(0 0 0 / 6%);
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
`,
cover: css`
position: absolute;
inset: 0;
opacity: 0.15;
background-position: center;
background-size: cover;
`,
desc: css`
margin: 0;
font-size: 13px;
line-height: 1.5;
`,
desc_dark: css`
color: rgb(255 255 255 / 50%);
`,
desc_light: css`
color: rgb(0 0 0 / 50%);
`,
title: css`
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
interface CollectionCardProps extends SkillCollectionItem {}
const CollectionCard = memo<CollectionCardProps>(({ title, summary, slug, cover }) => {
const isDark = useIsDark();
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(urlJoin('/community/collection', slug));
}, [navigate, slug]);
return (
<Block
clickable
className={cx(styles.card, isDark ? styles.card_dark : styles.card_light)}
style={{ cursor: 'pointer' }}
onClick={handleClick}
>
{cover && <div className={styles.cover} style={{ backgroundImage: `url(${cover})` }} />}
<Flexbox gap={8} style={{ position: 'relative', zIndex: 1 }}>
<Text className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{title}
</Text>
<Text
className={cx(styles.desc, isDark ? styles.desc_dark : styles.desc_light)}
ellipsis={{ rows: 3 }}
>
{summary}
</Text>
</Flexbox>
</Block>
);
});
const CollectionsSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
// Skip the first collection since it's shown in EditorsPick
const collections = data?.slice(1) || [];
if (isLoading || collections.length === 0) return null;
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.collections')}</Title>
<Grid maxItemWidth={400} rows={2} width={'100%'}>
{collections.slice(0, 4).map((item) => (
<CollectionCard key={item.slug} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default CollectionsSection;
@@ -0,0 +1,139 @@
'use client';
import { Button, Flexbox } from '@lobehub/ui';
import { Skeleton, Tag } from 'antd';
import { createStaticStyles, cx, responsive } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
const styles = createStaticStyles(({ css }) => ({
banner: css`
position: relative;
width: 100%;
padding-block: 32px;
padding-inline: 40px;
border-radius: 16px;
${responsive.sm} {
padding-block: 20px;
padding-inline: 24px;
}
`,
banner_dark: css`
background: linear-gradient(135deg, #2a2a2a 0%, #3a3a3a 50%, #2a2a2a 100%);
`,
banner_light: css`
background: linear-gradient(135deg, #e8e8e8 0%, #f5f5f5 50%, #e0e0e0 100%);
`,
description: css`
margin: 0;
font-size: 14px;
font-weight: 400;
line-height: 1.6;
${responsive.sm} {
font-size: 13px;
}
`,
description_dark: css`
color: rgb(255 255 255 / 65%);
`,
description_light: css`
color: rgb(0 0 0 / 65%);
`,
tag: css`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
`,
title: css`
margin: 0;
font-size: 28px;
font-weight: 700;
line-height: 1.3;
${responsive.sm} {
font-size: 22px;
}
`,
title_dark: css`
color: rgb(255 255 255 / 88%);
`,
title_light: css`
color: rgb(0 0 0 / 88%);
`,
}));
const EditorsPick = memo(() => {
const { t } = useTranslation('discover');
const isDark = useIsDark();
const navigate = useNavigate();
const useFetchSkillCollections = useDiscoverStore((s) => s.useFetchSkillCollections);
const { data, isLoading } = useFetchSkillCollections();
const collection = data?.[0];
const handleLearnMore = useCallback(() => {
if (collection?.slug) {
navigate(urlJoin('/community/collection', collection.slug));
}
}, [navigate, collection?.slug]);
if (isLoading) {
return (
<Flexbox
className={cx(styles.banner, isDark ? styles.banner_dark : styles.banner_light)}
width={'100%'}
>
<Flexbox gap={12} style={{ maxWidth: 500 }}>
<Skeleton.Button active size="small" style={{ width: 80 }} />
<Skeleton.Input active size="large" style={{ width: 300 }} />
<Skeleton active paragraph={{ rows: 2 }} title={false} />
</Flexbox>
</Flexbox>
);
}
if (!collection) return null;
return (
<Flexbox
className={cx(styles.banner, isDark ? styles.banner_dark : styles.banner_light)}
width={'100%'}
>
<Flexbox gap={12} style={{ maxWidth: 600, position: 'relative', zIndex: 1 }}>
<Tag className={styles.tag} color={isDark ? 'default' : 'default'}>
{t('skills.sections.editorsPick')}
</Tag>
<h2 className={cx(styles.title, isDark ? styles.title_dark : styles.title_light)}>
{collection.title}
</h2>
<p
className={cx(
styles.description,
isDark ? styles.description_dark : styles.description_light,
)}
>
{collection.summary}
</p>
<Flexbox horizontal gap={12} style={{ marginBlockStart: 8 }}>
<Button
style={{ background: isDark ? '#333' : '#333', color: '#fff' }}
onClick={handleLearnMore}
>
{t('skills.sections.learnMore')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
);
});
export default EditorsPick;
@@ -0,0 +1,113 @@
'use client';
import { Avatar, Block, Flexbox, Icon, Tag, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { StarIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { discoverService } from '@/services/discover';
import { type DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
author: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
category: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
desc: css`
flex: 1;
margin: 0 !important;
font-size: 13px;
color: ${cssVar.colorTextSecondary};
`,
name: css`
margin: 0 !important;
font-size: 15px !important;
font-weight: 600 !important;
`,
rating: css`
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorWarning};
`,
}));
const FeaturedCard = memo<DiscoverSkillItem>(
({ name, icon, author, description, identifier, category, isFeatured, ratingAvg }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const link = urlJoin('/community/skill', identifier);
const handleClick = useCallback(() => {
discoverService
.reportSkillEvent({
event: 'click',
identifier,
source: location.pathname,
})
.catch(() => {});
navigate(link);
}, [identifier, link, navigate]);
return (
<Block
clickable
height={'100%'}
variant={'outlined'}
width={'100%'}
style={{
overflow: 'hidden',
position: 'relative',
}}
onClick={handleClick}
>
<Flexbox gap={12} height={'100%'} padding={16}>
<Flexbox horizontal align={'center'} gap={12}>
<Avatar avatar={icon || name} shape={'square'} size={40} style={{ flex: 'none' }} />
<Flexbox flex={1} gap={2} style={{ overflow: 'hidden' }}>
<Flexbox horizontal align={'center'} gap={8}>
<Text ellipsis className={styles.name}>
{name}
</Text>
{isFeatured && (
<Tag color={'orange'} size={'small'}>
{t('isFeatured')}
</Tag>
)}
</Flexbox>
<Text ellipsis className={styles.author}>
{author}
</Text>
</Flexbox>
</Flexbox>
<Text
className={styles.desc}
ellipsis={{
rows: 2,
}}
>
{description}
</Text>
<Flexbox horizontal align={'center'} justify={'space-between'}>
<Tag size={'small'}>{category && t(`skills.categories.${category}.name` as any)}</Tag>
{Boolean(ratingAvg) && (
<Flexbox horizontal align={'center'} className={styles.rating} gap={4}>
<Icon icon={StarIcon} size={14} />
{ratingAvg?.toFixed(1)}
</Flexbox>
)}
</Flexbox>
</Flexbox>
</Block>
);
},
);
export default FeaturedCard;
@@ -0,0 +1,54 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { SkillSorts } from '@/types/discover';
import FeaturedCard from './FeaturedCard';
const FeaturedSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
order: 'desc',
page: 1,
pageSize: 6,
sort: SkillSorts.InstallCount,
});
if (isLoading) {
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.featured')}</Title>
<Grid rows={3} width={'100%'}>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton.Button active block key={index} style={{ height: 160 }} />
))}
</Grid>
</Flexbox>
);
}
if (!data?.items?.length) return null;
return (
<Flexbox gap={16}>
<Title more={t('skills.sections.seeAll')} moreLink={'/community/skill?sort=createdAt'}>
{t('skills.sections.featured')}
</Title>
<Grid rows={3} width={'100%'}>
{data.items.slice(0, 6).map((item) => (
<FeaturedCard key={item.identifier} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default FeaturedSection;
@@ -0,0 +1,85 @@
'use client';
import { Avatar, Block, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { discoverService } from '@/services/discover';
import { type DiscoverSkillItem } from '@/types/discover';
const styles = createStaticStyles(({ css }) => ({
category: css`
font-size: 12px;
color: ${cssVar.colorTextDescription};
`,
count: css`
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorTextSecondary};
`,
name: css`
margin: 0 !important;
font-size: 15px !important;
font-weight: 600 !important;
`,
rank: css`
font-size: 24px;
font-weight: 700;
color: ${cssVar.colorTextDescription};
`,
}));
interface TrendingCardProps extends DiscoverSkillItem {
rank: number;
}
const TrendingCard = memo<TrendingCardProps>(
({ rank, name, icon, identifier, category, installCount }) => {
const { t } = useTranslation('discover');
const navigate = useNavigate();
const link = urlJoin('/community/skill', identifier);
const handleClick = useCallback(() => {
discoverService
.reportSkillEvent({
event: 'click',
identifier,
source: location.pathname,
})
.catch(() => {});
navigate(link);
}, [identifier, link, navigate]);
return (
<Block
clickable
variant={'outlined'}
style={{
overflow: 'hidden',
position: 'relative',
}}
onClick={handleClick}
>
<Flexbox horizontal align={'center'} gap={16} padding={16}>
<Text className={styles.rank}>{rank}</Text>
<Avatar avatar={icon || name} shape={'square'} size={40} style={{ flex: 'none' }} />
<Flexbox flex={1} gap={2} style={{ overflow: 'hidden' }}>
<Text ellipsis className={styles.name}>
{name}
</Text>
<Text className={styles.category}>
{category && t(`skills.categories.${category}.name` as any)}
</Text>
</Flexbox>
<Text className={styles.count}>{installCount?.toLocaleString()}</Text>
</Flexbox>
</Block>
);
},
);
export default TrendingCard;
@@ -0,0 +1,54 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Title from '@/routes/(main)/community/components/Title';
import { useDiscoverStore } from '@/store/discover';
import { SkillSorts } from '@/types/discover';
import TrendingCard from './TrendingCard';
const TrendingSection = memo(() => {
const { t } = useTranslation('discover');
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
order: 'desc',
page: 1,
pageSize: 4,
sort: SkillSorts.InstallCount,
});
if (isLoading) {
return (
<Flexbox gap={16}>
<Title>{t('skills.sections.trending')}</Title>
<Grid maxItemWidth={300} rows={2} width={'100%'}>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton.Button active block key={index} style={{ height: 72 }} />
))}
</Grid>
</Flexbox>
);
}
if (!data?.items?.length) return null;
return (
<Flexbox gap={16}>
<Title more={t('skills.sections.seeAll')} moreLink={'/community/skill?sort=installCount'}>
{t('skills.sections.trending')}
</Title>
<Grid maxItemWidth={300} rows={2} width={'100%'}>
{data.items.slice(0, 4).map((item, index) => (
<TrendingCard key={item.identifier} rank={index + 1} {...item} />
))}
</Grid>
</Flexbox>
);
});
export default TrendingSection;
@@ -0,0 +1,22 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import CollectionsSection from './CollectionsSection';
import EditorsPick from './EditorsPick';
import FeaturedSection from './FeaturedSection';
import TrendingSection from './TrendingSection';
const DiscoverView = memo(() => {
return (
<Flexbox gap={40} width={'100%'}>
<EditorsPick />
<TrendingSection />
<FeaturedSection />
<CollectionsSection />
</Flexbox>
);
});
export default DiscoverView;
@@ -0,0 +1,51 @@
'use client';
import { Flexbox, Grid } from '@lobehub/ui';
import { memo } from 'react';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab, type SkillQueryParams, SkillSorts } from '@/types/discover';
import SkillEmpty from '../../../../features/SkillEmpty';
import Pagination from '../../../features/Pagination';
import Loading from '../../loading';
import FeaturedCard from '../DiscoverView/FeaturedSection/FeaturedCard';
const FilteredListView = memo(() => {
const { q, page, category, sort, order } = useQuery() as SkillQueryParams;
const useFetchSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useFetchSkillList({
category,
order,
page,
pageSize: 21,
q,
sort: sort ?? SkillSorts.InstallCount,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
if (items.length === 0) return <SkillEmpty />;
return (
<Flexbox gap={32} width={'100%'}>
<Grid rows={3} width={'100%'}>
{items.map((item) => (
<FeaturedCard key={item.identifier} {...item} />
))}
</Grid>
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Skills}
total={totalCount}
/>
</Flexbox>
);
});
export default FilteredListView;
@@ -0,0 +1,70 @@
import { SkillCategory, SkillSorts } from '@/types/discover';
export enum SkillTabKey {
AILLMs = 'ai-llms',
CodingAgent = 'coding-agents-ides',
DevOpsCloud = 'devops-cloud',
Discover = 'discover',
New = 'new',
Productivity = 'productivity-tasks',
Trending = 'trending',
WebFrontend = 'web-frontend-development',
}
export interface SkillTabConfig {
category?: SkillCategory;
isCategory?: boolean;
key: SkillTabKey;
labelKey: string;
sort?: SkillSorts;
}
export const SKILL_TABS: SkillTabConfig[] = [
{
isCategory: false,
key: SkillTabKey.Discover,
labelKey: 'skills.tabs.discover',
},
{
isCategory: false,
key: SkillTabKey.Trending,
labelKey: 'skills.tabs.trending',
sort: SkillSorts.InstallCount,
},
{
isCategory: false,
key: SkillTabKey.New,
labelKey: 'skills.tabs.new',
sort: SkillSorts.CreatedAt,
},
{
category: SkillCategory.CodingAgentsIDEs,
isCategory: true,
key: SkillTabKey.CodingAgent,
labelKey: 'skills.categories.coding-agents-ides.name',
},
{
category: SkillCategory.WebFrontendDevelopment,
isCategory: true,
key: SkillTabKey.WebFrontend,
labelKey: 'skills.categories.web-frontend-development.name',
},
{
category: SkillCategory.DevOpsCloud,
isCategory: true,
key: SkillTabKey.DevOpsCloud,
labelKey: 'skills.categories.devops-cloud.name',
},
{
category: SkillCategory.AILLMs,
isCategory: true,
key: SkillTabKey.AILLMs,
labelKey: 'skills.categories.ai-llms.name',
},
{
category: SkillCategory.ProductivityTasks,
isCategory: true,
key: SkillTabKey.Productivity,
labelKey: 'skills.categories.productivity-tasks.name',
},
];
@@ -0,0 +1,90 @@
'use client';
import { Tabs } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { type SkillQueryParams, SkillSorts } from '@/types/discover';
import { SKILL_TABS, SkillTabKey } from './const';
const styles = createStaticStyles(({ css, cssVar }) => ({
tabs: css`
.ant-tabs-nav {
margin-block-end: 0;
&::before {
border-block-end: none;
}
}
.ant-tabs-tab {
padding-block: 8px;
padding-inline: 16px;
border-radius: 8px;
&:hover {
background: ${cssVar.colorFillTertiary};
}
}
.ant-tabs-tab-active {
border: 1px solid ${cssVar.colorBorder};
background: ${cssVar.colorFillSecondary};
.ant-tabs-tab-btn {
color: ${cssVar.colorText};
}
}
.ant-tabs-ink-bar {
display: none;
}
`,
}));
const TabNavigation = memo(() => {
const { t } = useTranslation('discover');
const router = useQueryRoute();
const { category, sort } = useQuery() as SkillQueryParams;
const activeTab = useMemo(() => {
if (!category && !sort) return SkillTabKey.Discover;
if (sort === SkillSorts.InstallCount && !category) return SkillTabKey.Trending;
if (sort === SkillSorts.CreatedAt && !category) return SkillTabKey.New;
const categoryTab = SKILL_TABS.find((tab) => tab.category === category);
return categoryTab?.key ?? SkillTabKey.Discover;
}, [category, sort]);
const handleTabChange = (key: string) => {
const tab = SKILL_TABS.find((item) => item.key === key);
if (!tab) return;
if (tab.key === SkillTabKey.Discover) {
router.push('/community/skill', { query: {}, replace: true });
} else if (tab.isCategory && tab.category) {
router.push('/community/skill', { query: { category: tab.category }, replace: true });
} else if (tab.sort) {
router.push('/community/skill', { query: { sort: tab.sort }, replace: true });
}
};
const items = useMemo(
() =>
SKILL_TABS.map((tab) => ({
key: tab.key,
label: t(tab.labelKey as any),
})),
[t],
);
return (
<Tabs activeKey={activeTab} className={styles.tabs} items={items} onChange={handleTabChange} />
);
});
export default TabNavigation;
@@ -1,42 +1,26 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { type SkillQueryParams } from '@/types/discover';
import { DiscoverTab, SkillSorts } from '@/types/discover';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
import DiscoverView from './features/DiscoverView';
import FilteredListView from './features/FilteredListView';
import TabNavigation from './features/TabNavigation';
const SkillPage = memo(() => {
const { q, page, category, sort, order } = useQuery() as SkillQueryParams;
const useSkillList = useDiscoverStore((s) => s.useFetchSkillList);
const { data, isLoading } = useSkillList({
category,
order,
page,
pageSize: 21,
q,
sort: sort ?? SkillSorts.InstallCount,
});
const { category, sort, q } = useQuery() as SkillQueryParams;
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
const isDiscoverView = useMemo(() => {
return !category && !sort && !q;
}, [category, sort, q]);
return (
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Skills}
total={totalCount}
/>
<Flexbox gap={24} width={'100%'}>
<TabNavigation />
{isDiscoverView ? <DiscoverView /> : <FilteredListView />}
</Flexbox>
);
});
+47
View File
@@ -25,6 +25,53 @@ const marketProcedure = publicProcedure
});
export const skillRouter = router({
getSkillCollectionDetail: marketProcedure
.input(
z.object({
locale: z.string().optional(),
slug: z.string(),
}),
)
.query(async ({ input, ctx }) => {
log('getSkillCollectionDetail input: %O', input);
try {
return await ctx.marketService.getSkillCollectionDetail(input.slug, {
locale: input.locale,
});
} catch (error) {
log('Error fetching skill collection detail: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch skill collection detail',
});
}
}),
getSkillCollections: marketProcedure
.input(
z
.object({
locale: z.string().optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
log('getSkillCollections input: %O', input);
try {
return await ctx.marketService.getSkillCollections({
locale: input?.locale,
});
} catch (error) {
log('Error fetching skill collections: %O', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch skill collections',
});
}
}),
getSkillCategories: marketProcedure
.input(
z
+55
View File
@@ -457,6 +457,61 @@ export class MarketService {
return this.market.marketSkills.getCategories();
}
/**
* Get skill collections list
*/
async getSkillCollections(options?: { locale?: string }) {
log('getSkillCollections: %O', options);
const url = new URL(`${MARKET_BASE_URL}/api/v1/skills/collections/`);
if (options?.locale) {
url.searchParams.set('locale', options.locale);
}
const response = await fetch(url.toString(), {
// @ts-ignore
headers: this.market.headers,
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch skill collections: ${response.status}`);
}
return response.json();
}
/**
* Get skill collection detail by slug
*/
async getSkillCollectionDetail(slug: string, options?: { locale?: string }) {
log('getSkillCollectionDetail: slug=%s, options=%O', slug, options);
const url = new URL(`${MARKET_BASE_URL}/api/v1/skills/collections/${slug}`);
if (options?.locale) {
url.searchParams.set('locale', options.locale);
}
const response = await fetch(url.toString(), {
// @ts-ignore
headers: this.market.headers,
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch skill collection detail: ${response.status}`);
}
const data = await response.json();
// Map items to skills for compatibility
if (data.items && !data.skills) {
data.skills = data.items;
}
return data;
}
/**
* Execute a LobeHub Skill tool
* @param params - The skill execution parameters (provider, toolName, args)
+15
View File
@@ -564,6 +564,21 @@ class DiscoverService {
});
};
getSkillCollections = async (): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.skill.getSkillCollections.query({
locale,
});
};
getSkillCollectionDetail = async (params: { slug: string }): Promise<any> => {
const locale = globalHelpers.getCurrentLanguage();
return lambdaClient.market.skill.getSkillCollectionDetail.query({
...params,
locale,
});
};
reportSkillEvent = async (eventData: { event: string; identifier: string; source?: string }) => {
const allow = userGeneralSettingsSelectors.telemetry(useUserStore.getState());
if (!allow) return;
@@ -22,6 +22,7 @@ import AgentProfilePage from '@/routes/(main)/agent/profile';
import CommunityLayout from '@/routes/(main)/community/_layout';
import CommunityDetailLayout from '@/routes/(main)/community/(detail)/_layout';
import CommunityDetailAgentPage from '@/routes/(main)/community/(detail)/agent';
import CommunityDetailCollectionPage from '@/routes/(main)/community/(detail)/collection';
import CommunityDetailGroupAgentPage from '@/routes/(main)/community/(detail)/group_agent';
import CommunityDetailMcpPage from '@/routes/(main)/community/(detail)/mcp';
import CommunityDetailModelPage from '@/routes/(main)/community/(detail)/model';
@@ -217,6 +218,10 @@ export const desktopRoutes: RouteObject[] = [
element: <CommunityDetailSkillPage />,
path: 'skill/:slug',
},
{
element: <CommunityDetailCollectionPage />,
path: 'collection/:slug',
},
{
element: <CommunityDetailMcpPage />,
path: 'mcp/:slug',
+7
View File
@@ -221,6 +221,13 @@ export const desktopRoutes: RouteObject[] = [
),
path: 'skill/:slug',
},
{
element: dynamicElement(
() => import('@/routes/(main)/community/(detail)/collection'),
'Desktop > Discover > Detail > Collection',
),
path: 'collection/:slug',
},
{
element: dynamicElement(
() => import('@/routes/(main)/community/(detail)/mcp'),
+10
View File
@@ -144,6 +144,16 @@ export const mobileRoutes: RouteObject[] = [
),
path: 'mcp/:slug',
},
{
element: dynamicElement(
() =>
import('@/routes/(main)/community/(detail)/collection').then(
(m) => m.MobileCollectionPage,
),
'Mobile > Discover > Detail > Collection',
),
path: 'collection/:slug',
},
{
element: dynamicElement(
() =>
+26
View File
@@ -9,6 +9,8 @@ import { type StoreSetter } from '@/store/types';
import {
type DiscoverSkillDetail,
type SkillCategoryItem,
type SkillCollectionDetail,
type SkillCollectionListResponse,
type SkillListResponse,
type SkillQueryParams,
} from '@/types/discover';
@@ -63,6 +65,30 @@ export class SkillActionImpl {
},
);
};
useFetchSkillCollections = (): SWRResponse<SkillCollectionListResponse> => {
const locale = globalHelpers.getCurrentLanguage();
return useClientDataSWR(
['skill-collections', locale].join('-'),
async () => discoverService.getSkillCollections(),
{
revalidateOnFocus: false,
},
);
};
useFetchSkillCollectionDetail = ({
slug,
}: {
slug?: string;
}): SWRResponse<SkillCollectionDetail> => {
const locale = globalHelpers.getCurrentLanguage();
return useClientDataSWR(
!slug ? null : ['skill-collection-detail', locale, slug].filter(Boolean).join('-'),
async () => discoverService.getSkillCollectionDetail({ slug: slug! }),
);
};
}
export type SkillAction = Pick<SkillActionImpl, keyof SkillActionImpl>;