mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge pull request #28 from iflytek/feature/project-local
Polish search, favorites, publish, token, and auth UX
This commit is contained in:
commit
9bc16f1e13
27 changed files with 310 additions and 140 deletions
|
|
@ -233,7 +233,7 @@ public class SecurityConfig {
|
|||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.ignoringRequestMatchers("/api/v1/cli/**"))
|
||||
.ignoringRequestMatchers("/api/v1/**"))
|
||||
// ...
|
||||
;
|
||||
}
|
||||
|
|
@ -458,7 +458,7 @@ Session 中存储以下字段:
|
|||
- 后端设置 `XSRF-TOKEN` Cookie(`HttpOnly=false`)
|
||||
- 前端从 Cookie 读取 Token,放入请求 Header `X-XSRF-TOKEN`
|
||||
- 后端校验 Header 与 Cookie 是否一致
|
||||
- CLI API(`/api/v1/cli/**`)与兼容层(`/api/compat/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie)
|
||||
- CLI API(`/api/v1/**`)与兼容层(`/api/compat/v1/**`)豁免 CSRF(使用 Bearer Token,无 Cookie)
|
||||
|
||||
## 9. 前端权限控制
|
||||
|
||||
|
|
@ -609,8 +609,8 @@ window.location.href = '/oauth2/authorization/github'
|
|||
|
||||
| 接口 | 所需凭证 | 额外判定 |
|
||||
|------|---------|---------|
|
||||
| `GET /api/v1/cli/whoami` | 任意有效 Bearer Token | 无 |
|
||||
| `POST /api/v1/cli/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 |
|
||||
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
|
||||
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 |
|
||||
|
||||
### 10.4 Admin API
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DR
|
|||
- 审核通过后才转为 `PUBLISHED`
|
||||
- 审核拒绝后转为 `REJECTED`
|
||||
- 例外:提交人持有 `SUPER_ADMIN` 平台角色时,发布入口直接创建 `skill_version(status=PUBLISHED)`,跳过 `review_task` 创建,同时不再要求其必须是目标 namespace 成员
|
||||
- 上述例外必须对 Web、`/api/v1/cli/publish`、`/api/compat/v1/publish` 保持一致
|
||||
- 上述例外必须对 Web、`/api/v1/publish`、`/api/compat/v1/publish` 保持一致
|
||||
|
||||
### 对象存储写入策略
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ Phase 3 在不改变发布入口的前提下,把后半段切换为“创建 DR
|
|||
### CLI publish 请求规范
|
||||
|
||||
```
|
||||
POST /api/v1/cli/publish
|
||||
POST /api/v1/publish
|
||||
Content-Type: multipart/form-data
|
||||
Parts:
|
||||
- file: zip 包(必需)
|
||||
|
|
@ -81,7 +81,7 @@ Phase 3 CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核。
|
|||
如果调用方持有 `SUPER_ADMIN`,则直接发布为 `PUBLISHED`。
|
||||
Web 端可保留“发布后再提交审核”的两段式体验,但这属于 Phase 3 能力。
|
||||
|
||||
`/api/v1/cli/publish` 响应:
|
||||
`/api/v1/publish` 响应:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ Public API 的可见性规则:
|
|||
发布状态约束:
|
||||
|
||||
- 普通用户发布成功后,`status` 为 `PENDING_REVIEW`
|
||||
- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/cli/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员
|
||||
- 持有 `SUPER_ADMIN` 的用户通过 Web、`/api/v1/publish`、`/api/compat/v1/publish` 发布时,`status` 为 `PUBLISHED`,且不要求其必须是目标 namespace 成员
|
||||
|
||||
## 7.4 Token API(需登录)
|
||||
|
||||
|
|
@ -253,10 +253,10 @@ Public API 的可见性规则:
|
|||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/cli/whoami` | 当前 Bearer Token 对应的用户信息 |
|
||||
| POST | `/api/v1/cli/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) |
|
||||
| GET | `/api/v1/cli/resolve/{namespace}/{slug}` | 解析版本 |
|
||||
| GET | `/api/v1/cli/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 |
|
||||
| GET | `/api/v1/whoami` | 当前 Bearer Token 对应的用户信息 |
|
||||
| POST | `/api/v1/publish` | 发布技能包(Phase 2 直接返回 `PUBLISHED`,Phase 3 恢复审核流;`SUPER_ADMIN` 始终直发) |
|
||||
| GET | `/api/v1/resolve/{namespace}/{slug}` | 解析版本 |
|
||||
| GET | `/api/v1/check/{namespace}/{slug}/{version}` | 本地哈希与远端比对 |
|
||||
|
||||
### ClawHub CLI 协议兼容层
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ Public API 的可见性规则:
|
|||
- 范围:一期聚焦覆盖 ClawHub CLI 所依赖的核心接口:查询、版本解析、下载、发布、whoami
|
||||
- 要求:兼容层优先保持 ClawHub CLI 既有请求/响应语义;若内部领域模型不同,通过 adapter 层完成协议转换,而不是要求客户端适配 skillhub 私有协议
|
||||
- 要求:兼容层纳入 OpenAPI 或独立兼容协议文档,并作为正式对外契约维护
|
||||
- 要求:兼容层与 skillhub 自有 `/api/v1/cli/**` 并存,二者共享同一套权限、审计、限流与领域服务
|
||||
- 要求:兼容层与 skillhub 自有 `/api/v1/**` 并存,二者共享同一套权限、审计、限流与领域服务
|
||||
- 非目标:前端页面不直接依赖兼容层;兼容层用于服务已有 ClawHub CLI 和相关自动化脚本
|
||||
|
||||
兼容层最少需要覆盖的能力类别:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/cli")
|
||||
@RequestMapping("/api/v1")
|
||||
public class CliController extends BaseApiController {
|
||||
|
||||
private final SkillPackageValidator skillPackageValidator;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/cli/auth/device")
|
||||
@RequestMapping("/api/v1/auth/device")
|
||||
public class DeviceAuthController extends BaseApiController {
|
||||
|
||||
private final DeviceAuthService deviceAuthService;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import java.io.IOException;
|
|||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/cli")
|
||||
@RequestMapping("/api/v1")
|
||||
public class CliPublishController extends BaseApiController {
|
||||
|
||||
private final SkillPublishService skillPublishService;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class CliControllerTest {
|
|||
|
||||
@Test
|
||||
void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/cli/whoami"))
|
||||
mockMvc.perform(get("/api/v1/whoami"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ class CliControllerTest {
|
|||
List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/v1/cli/whoami").with(authentication(auth)))
|
||||
mockMvc.perform(get("/api/v1/whoami").with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.msg").isNotEmpty())
|
||||
|
|
@ -88,7 +88,7 @@ class CliControllerTest {
|
|||
zipBytes
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
|
||||
mockMvc.perform(multipart("/api/v1/check").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.valid").value(true))
|
||||
|
|
@ -107,7 +107,7 @@ class CliControllerTest {
|
|||
zipBytes
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
|
||||
mockMvc.perform(multipart("/api/v1/check").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.valid").value(false))
|
||||
|
|
@ -125,7 +125,7 @@ class CliControllerTest {
|
|||
zipBytes
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
|
||||
mockMvc.perform(multipart("/api/v1/check").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.valid").value(false))
|
||||
|
|
@ -142,7 +142,7 @@ class CliControllerTest {
|
|||
zipBytes
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/cli/check").file(file))
|
||||
mockMvc.perform(multipart("/api/v1/check").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.valid").value(false))
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class DeviceAuthControllerTest {
|
|||
|
||||
given(deviceAuthService.generateDeviceCode()).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/cli/auth/device/code")
|
||||
mockMvc.perform(post("/api/v1/auth/device/code")
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
|
|
@ -61,7 +61,7 @@ class DeviceAuthControllerTest {
|
|||
|
||||
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/cli/auth/device/token")
|
||||
mockMvc.perform(post("/api/v1/auth/device/token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"deviceCode\": \"device_abc123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
|
@ -77,7 +77,7 @@ class DeviceAuthControllerTest {
|
|||
|
||||
given(deviceAuthService.pollToken("device_abc123")).willReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/v1/cli/auth/device/token")
|
||||
mockMvc.perform(post("/api/v1/auth/device/token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"deviceCode\": \"device_abc123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ public class SecurityConfig {
|
|||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.csrfTokenRequestHandler(csrfHandler)
|
||||
.ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**")
|
||||
.ignoringRequestMatchers("/api/v1/**", "/api/compat/**")
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
|
|
@ -81,8 +81,8 @@ public class SecurityConfig {
|
|||
"/api/v1/auth/session/bootstrap",
|
||||
"/api/v1/auth/direct/login",
|
||||
"/api/v1/auth/local/**",
|
||||
"/api/v1/cli/auth/device/**",
|
||||
"/api/v1/cli/check",
|
||||
"/api/v1/auth/device/**",
|
||||
"/api/v1/check",
|
||||
"/actuator/health",
|
||||
"/actuator/prometheus",
|
||||
"/v3/api-docs/**",
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ public class ApiTokenScopeService {
|
|||
ScopeRule.allow(null, "/api/v1/health"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/providers"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/me"),
|
||||
ScopeRule.allow(null, "/api/v1/cli/auth/device/**"),
|
||||
ScopeRule.allow(null, "/api/v1/cli/check"),
|
||||
ScopeRule.allow("GET", "/api/v1/cli/whoami"),
|
||||
ScopeRule.allow(null, "/api/v1/auth/device/**"),
|
||||
ScopeRule.allow(null, "/api/v1/check"),
|
||||
ScopeRule.allow("GET", "/api/v1/whoami"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills"),
|
||||
ScopeRule.allow("GET", "/api/v1/skills/**"),
|
||||
ScopeRule.allow("GET", "/api/v1/namespaces"),
|
||||
|
|
@ -39,7 +39,7 @@ public class ApiTokenScopeService {
|
|||
ScopeRule.require(null, "/api/v1/tokens", "token:manage"),
|
||||
ScopeRule.require(null, "/api/v1/tokens/**", "token:manage"),
|
||||
ScopeRule.require("POST", "/api/v1/skills/*/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/cli/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/v1/publish", "skill:publish"),
|
||||
ScopeRule.require("POST", "/api/compat/v1/publish", "skill:publish")
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class ApiTokenAuthenticationFilterTest {
|
|||
when(role.getCode()).thenReturn("SKILL_ADMIN");
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/v1/cli/whoami");
|
||||
request.setRequestURI("/api/v1/whoami");
|
||||
request.addHeader("Authorization", "Bearer raw-token");
|
||||
|
||||
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
|
||||
|
|
@ -84,7 +84,7 @@ class ApiTokenAuthenticationFilterTest {
|
|||
when(userAccountRepository.findById("user-2")).thenReturn(Optional.of(user));
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setRequestURI("/api/v1/cli/publish");
|
||||
request.setRequestURI("/api/v1/publish");
|
||||
request.addHeader("Authorization", "Bearer raw-token");
|
||||
|
||||
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class ApiTokenScopeFilterTest {
|
|||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/cli/publish");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/publish");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class ApiTokenScopeServiceTest {
|
|||
void authorizeShouldAllowCliWhoamiWithoutScope() {
|
||||
ApiTokenScopeService.AuthorizationDecision decision = scopeService.authorize(
|
||||
"GET",
|
||||
"/api/v1/cli/whoami",
|
||||
"/api/v1/whoami",
|
||||
Set.of()
|
||||
);
|
||||
|
||||
|
|
|
|||
12
web/src/api/generated/schema.d.ts
vendored
12
web/src/api/generated/schema.d.ts
vendored
|
|
@ -308,7 +308,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/publish": {
|
||||
"/api/v1/publish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -324,7 +324,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/check": {
|
||||
"/api/v1/check": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -340,7 +340,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/auth/device/token": {
|
||||
"/api/v1/auth/device/token": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -356,7 +356,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/auth/device/code": {
|
||||
"/api/v1/auth/device/code": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -916,7 +916,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/whoami": {
|
||||
"/api/v1/whoami": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
@ -932,7 +932,7 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/cli/resolve/{namespace}/{slug}": {
|
||||
"/api/v1/resolve/{namespace}/{slug}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export interface SearchParams {
|
|||
sort?: string
|
||||
page?: number
|
||||
size?: number
|
||||
starredOnly?: boolean
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function Layout() {
|
|||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0 }}
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
activeProps={{ className: 'text-primary' }}
|
||||
>
|
||||
|
|
@ -109,7 +109,7 @@ export function Layout() {
|
|||
<li>
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0 }}
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{t('nav.search')}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ const searchRoute = createRoute({
|
|||
q: (search.q as string) || '',
|
||||
sort: (search.sort as string) || 'relevance',
|
||||
page: Number(search.page) || 0,
|
||||
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,57 +1,117 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { useStar, useToggleStar } from '@/features/social/use-star'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { Bookmark } from 'lucide-react'
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillSummary
|
||||
onClick?: () => void
|
||||
highlightStarred?: boolean
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, onClick }: SkillCardProps) {
|
||||
export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated)
|
||||
const toggleStarMutation = useToggleStar(skill.id)
|
||||
const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred
|
||||
|
||||
const handleStarredBadgeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmUnstar = async () => {
|
||||
if (!starStatus?.starred) {
|
||||
return
|
||||
}
|
||||
await toggleStarMutation.mutateAsync(starStatus.starred)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Hover gradient border effect */}
|
||||
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
|
||||
<>
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Hover gradient border effect */}
|
||||
<div className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-br from-primary/20 via-transparent to-accent/20 pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold font-heading text-lg text-foreground group-hover:text-primary transition-colors">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showStarredBadge ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary/25 bg-primary/12 px-2.5 py-1 text-[11px] font-semibold text-primary shadow-sm transition-colors hover:bg-primary/18"
|
||||
aria-label={t('skillCard.starred')}
|
||||
title={t('skillCard.starredAction')}
|
||||
onClick={handleStarredBadgeClick}
|
||||
>
|
||||
<Bookmark className="h-3.5 w-3.5 fill-current" />
|
||||
{t('skillCard.starred')}
|
||||
</button>
|
||||
) : null}
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{skill.latestVersion}
|
||||
</span>
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.downloadCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{skill.latestVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
v{skill.latestVersion}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)} ({skill.ratingCount})
|
||||
{skill.downloadCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}
|
||||
aria-label={showStarredBadge ? t('skillCard.starred') : undefined}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredBadge ? 'fill-current' : ''}`} />
|
||||
{skill.starCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t('skillCard.unstarTitle')}
|
||||
description={t('skillCard.unstarDescription', { name: skill.displayName })}
|
||||
confirmText={t('skillCard.unstarConfirm')}
|
||||
onConfirm={handleConfirmUnstar}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { useStar, useToggleStar } from './use-star'
|
||||
import { Star } from 'lucide-react'
|
||||
import { Bookmark } from 'lucide-react'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
|
||||
interface StarButtonProps {
|
||||
|
|
@ -34,10 +34,11 @@ export function StarButton({ skillId, starCount, onRequireLogin }: StarButtonPro
|
|||
<Button
|
||||
variant={starStatus.starred ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="justify-between"
|
||||
onClick={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
<Star className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
|
||||
<Bookmark className={`w-4 h-4 mr-2 ${starStatus.starred ? 'fill-current' : ''}`} />
|
||||
{starStatus.starred ? t('starButton.starred') : t('starButton.star')} ({starCount})
|
||||
</Button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,11 +31,11 @@ async function toggleStar(skillId: number, starred: boolean): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export function useStar(skillId: number) {
|
||||
export function useStar(skillId: number, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', skillId, 'star'],
|
||||
queryFn: () => getStarStatus(skillId),
|
||||
enabled: !!skillId,
|
||||
enabled: !!skillId && enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@
|
|||
"search": {
|
||||
"title": "Search Skills",
|
||||
"placeholder": "Search skills...",
|
||||
"filters": {
|
||||
"label": "Filter:"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sort:",
|
||||
"relevance": "Relevance",
|
||||
|
|
@ -94,6 +97,10 @@
|
|||
},
|
||||
"noResults": "No results found",
|
||||
"noResultsFor": "No skills found matching \"{{q}}\"",
|
||||
"filterStarred": "Starred only",
|
||||
"noStarredResults": "No starred skills found",
|
||||
"noStarredResultsFor": "No starred skills match \"{{q}}\"",
|
||||
"noStarredSkills": "You have not starred any skills yet",
|
||||
"enterKeyword": "Please enter a search keyword",
|
||||
"results": "{{count}} skills found",
|
||||
"resultCount": "Found <1>{{count}}</1> results"
|
||||
|
|
@ -415,6 +422,13 @@
|
|||
"starred": "Starred",
|
||||
"star": "Star"
|
||||
},
|
||||
"skillCard": {
|
||||
"starred": "Starred",
|
||||
"starredAction": "Click to unstar",
|
||||
"unstarTitle": "Remove from starred",
|
||||
"unstarDescription": "Are you sure you want to remove \"{{name}}\" from your starred skills?",
|
||||
"unstarConfirm": "Remove"
|
||||
},
|
||||
"copyButton": {
|
||||
"copied": "Copied",
|
||||
"copy": "Copy"
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@
|
|||
"search": {
|
||||
"title": "搜索技能",
|
||||
"placeholder": "搜索技能...",
|
||||
"filters": {
|
||||
"label": "筛选:"
|
||||
},
|
||||
"sort": {
|
||||
"label": "排序:",
|
||||
"relevance": "相关性",
|
||||
|
|
@ -94,6 +97,10 @@
|
|||
},
|
||||
"noResults": "未找到结果",
|
||||
"noResultsFor": "没有找到与 \"{{q}}\" 相关的技能",
|
||||
"filterStarred": "只看已收藏",
|
||||
"noStarredResults": "未找到已收藏技能",
|
||||
"noStarredResultsFor": "已收藏技能中没有与 \"{{q}}\" 相关的结果",
|
||||
"noStarredSkills": "你还没有收藏任何技能",
|
||||
"enterKeyword": "请输入搜索关键词",
|
||||
"results": "找到 {{count}} 个技能",
|
||||
"resultCount": "找到 <1>{{count}}</1> 个结果"
|
||||
|
|
@ -415,6 +422,13 @@
|
|||
"starred": "已收藏",
|
||||
"star": "收藏"
|
||||
},
|
||||
"skillCard": {
|
||||
"starred": "已收藏",
|
||||
"starredAction": "点击取消收藏",
|
||||
"unstarTitle": "取消收藏",
|
||||
"unstarDescription": "确定要取消收藏技能“{{name}}”吗?",
|
||||
"unstarConfirm": "取消收藏"
|
||||
},
|
||||
"copyButton": {
|
||||
"copied": "已复制",
|
||||
"copy": "复制"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function HomePage() {
|
|||
})
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0 } })
|
||||
navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0, starredOnly: false } })
|
||||
}
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
|
|
@ -49,7 +49,7 @@ export function HomePage() {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
|
||||
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}>
|
||||
<Button size="lg" onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}>
|
||||
{t('home.browseSkills')}
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
|
|
@ -67,7 +67,7 @@ export function HomePage() {
|
|||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0 } })}
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('home.viewAll')}
|
||||
</Button>
|
||||
|
|
@ -97,7 +97,7 @@ export function HomePage() {
|
|||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0 } })}
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('home.viewAll')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function LandingPage() {
|
|||
}, [])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0 } })
|
||||
navigate({ to: '/search', search: { q: query, sort: 'relevance', page: 0, starredOnly: false } })
|
||||
}
|
||||
|
||||
const features = [
|
||||
|
|
@ -216,7 +216,7 @@ export function LandingPage() {
|
|||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-white font-semibold px-8 py-6 text-lg rounded-xl shadow-lg shadow-cyan-500/25 hover:shadow-cyan-500/40 transition-all duration-300 hover:scale-105"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })}
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('landing.hero.exploreSkills')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,52 @@
|
|||
import { startTransition, useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { SearchBar } from '@/features/search/search-bar'
|
||||
import { SkillCard } from '@/features/skill/skill-card'
|
||||
import { SkeletonList } from '@/shared/components/skeleton-loader'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useMyStars, useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
function filterStarredSkills(skills: SkillSummary[], query: string): SkillSummary[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) {
|
||||
return skills
|
||||
}
|
||||
|
||||
return skills.filter((skill) =>
|
||||
[skill.displayName, skill.summary, skill.namespace, skill.slug]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(normalizedQuery))
|
||||
)
|
||||
}
|
||||
|
||||
function sortStarredSkills(skills: SkillSummary[], sort: string): SkillSummary[] {
|
||||
const sorted = [...skills]
|
||||
if (sort === 'downloads') {
|
||||
return sorted.sort((left, right) => right.downloadCount - left.downloadCount)
|
||||
}
|
||||
if (sort === 'newest' || sort === 'relevance') {
|
||||
return sorted.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
export function SearchPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const searchParams = useSearch({ from: '/search' })
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const q = searchParams.q || ''
|
||||
const sort = searchParams.sort || 'relevance'
|
||||
const page = searchParams.page ?? 0
|
||||
const starredOnly = searchParams.starredOnly ?? false
|
||||
const [queryInput, setQueryInput] = useState(q)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -27,8 +57,10 @@ export function SearchPage() {
|
|||
q,
|
||||
sort,
|
||||
page,
|
||||
size: 12,
|
||||
size: PAGE_SIZE,
|
||||
starredOnly,
|
||||
})
|
||||
const { data: starredSkills, isLoading: isLoadingStarred } = useMyStars(starredOnly && isAuthenticated)
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = queryInput.trim()
|
||||
|
|
@ -38,41 +70,68 @@ export function SearchPage() {
|
|||
|
||||
if (!normalizedQuery) {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 })
|
||||
navigate({ to: '/search', search: { q: '', sort, page: 0, starredOnly }, replace: page === 0 })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [navigate, page, q, queryInput, sort])
|
||||
}, [navigate, page, q, queryInput, sort, starredOnly])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
const normalizedQuery = query.trim()
|
||||
setQueryInput(query)
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
|
||||
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSortChange = (newSort: string) => {
|
||||
navigate({ to: '/search', search: { q, sort: newSort, page: 0 } })
|
||||
navigate({ to: '/search', search: { q, sort: newSort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
navigate({ to: '/search', search: { q, sort, page: newPage } })
|
||||
navigate({ to: '/search', search: { q, sort, page: newPage, starredOnly } })
|
||||
}
|
||||
|
||||
const handleStarredToggle = () => {
|
||||
if (!isAuthenticated) {
|
||||
navigate({
|
||||
to: '/login',
|
||||
search: {
|
||||
returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
navigate({ to: '/search', search: { q, sort, page: 0, starredOnly: !starredOnly } })
|
||||
}
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
navigate({ to: `/space/${namespace}/${slug}` })
|
||||
}
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.size) : 0
|
||||
const filteredStarredSkills = starredOnly
|
||||
? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q), sort)
|
||||
: []
|
||||
const starredPageItems = starredOnly
|
||||
? filteredStarredSkills.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
: []
|
||||
const totalPages = starredOnly
|
||||
? Math.ceil(filteredStarredSkills.length / PAGE_SIZE)
|
||||
: data
|
||||
? Math.ceil(data.total / data.size)
|
||||
: 0
|
||||
const displayItems = starredOnly ? starredPageItems : (data?.items ?? [])
|
||||
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
|
||||
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
|
|
@ -81,52 +140,66 @@ export function SearchPage() {
|
|||
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{/* Sort Selector */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={sort === 'relevance' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('relevance')}
|
||||
>
|
||||
{t('search.sort.relevance')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'downloads' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('downloads')}
|
||||
>
|
||||
{t('search.sort.downloads')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'newest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('newest')}
|
||||
>
|
||||
{t('search.sort.newest')}
|
||||
</Button>
|
||||
{/* Sort And Filters */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={sort === 'relevance' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('relevance')}
|
||||
>
|
||||
{t('search.sort.relevance')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'downloads' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('downloads')}
|
||||
>
|
||||
{t('search.sort.downloads')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sort === 'newest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('newest')}
|
||||
>
|
||||
{t('search.sort.newest')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultCount > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('search.results', { count: resultCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && data.total > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('search.results', { count: data.total })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
|
||||
<Button
|
||||
variant={starredOnly ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleStarredToggle}
|
||||
>
|
||||
{t('search.filterStarred')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<SkeletonList count={12} />
|
||||
) : data && data.items.length > 0 ? (
|
||||
{isPageLoading ? (
|
||||
<SkeletonList count={PAGE_SIZE} />
|
||||
) : displayItems.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{data.items.map((skill, idx) => (
|
||||
{displayItems.map((skill, idx) => (
|
||||
<div key={skill.id} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
highlightStarred
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -142,8 +215,12 @@ export function SearchPage() {
|
|||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('search.noResults')}
|
||||
description={q ? t('search.noResultsFor', { q }) : t('search.enterKeyword')}
|
||||
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
|
||||
description={
|
||||
starredOnly
|
||||
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
|
||||
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function SkillDetailPage() {
|
|||
window.history.back()
|
||||
return
|
||||
}
|
||||
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })
|
||||
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })
|
||||
}
|
||||
|
||||
if (isLoadingSkill) {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export function useSearchSkills(params: SearchParams) {
|
|||
return useQuery({
|
||||
queryKey: ['skills', 'search', params],
|
||||
queryFn: () => searchSkills(params),
|
||||
enabled: params.starredOnly !== true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -128,10 +129,11 @@ export function useMySkills() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useMyStars() {
|
||||
export function useMyStars(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['skills', 'stars'],
|
||||
queryFn: getMyStars,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue