mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 19:50:09 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e96924774 | |||
| 4b9ddc4fc2 | |||
| f0b16a5565 | |||
| 97987229a8 | |||
| 4aa6dcd01a |
@@ -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",
|
||||
|
||||
@@ -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": "模型",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+122
@@ -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;
|
||||
+113
@@ -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;
|
||||
+54
@@ -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;
|
||||
+85
@@ -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;
|
||||
+54
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user