diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index f5869eb1..0dd5f2ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -34,6 +34,8 @@ public class MeController extends BaseApiController { @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String filter, + @RequestParam(required = false) String q, + @RequestParam(required = false) String namespace, @AuthenticationPrincipal PlatformPrincipal principal) { if (principal == null) { throw new UnauthorizedException("error.auth.required"); @@ -41,7 +43,7 @@ public class MeController extends BaseApiController { return ok( "response.success.read", - mySkillAppService.listMySkills(principal.userId(), page, size, filter, principal.platformRoles()) + mySkillAppService.listMySkills(principal.userId(), page, size, filter, q, namespace, principal.platformRoles()) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 9ad9e6f3..e36dc201 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; @@ -36,6 +38,7 @@ public class MySkillAppService { private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final NamespaceRepository namespaceRepository; public MySkillAppService( SkillRepository skillRepository, @@ -43,17 +46,19 @@ public class MySkillAppService { SkillStarRepository skillStarRepository, SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, - SkillLifecycleProjectionService skillLifecycleProjectionService) { + SkillLifecycleProjectionService skillLifecycleProjectionService, + NamespaceRepository namespaceRepository) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarRepository = skillStarRepository; this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.namespaceRepository = namespaceRepository; } public PageResponse listMySkills(String userId, int page, int size) { - return listMySkills(userId, page, size, null, java.util.Set.of()); + return listMySkills(userId, page, size, null, null, null, java.util.Set.of()); } public PageResponse listMySkills(String userId, @@ -61,10 +66,27 @@ public class MySkillAppService { int size, String filter, java.util.Set platformRoles) { + return listMySkills(userId, page, size, filter, null, null, platformRoles); + } + + public PageResponse listMySkills(String userId, + int page, + int size, + String filter, + String keyword, + String namespace, + java.util.Set platformRoles) { MySkillFilter normalizedFilter = parseFilter(filter); - Page skillPage = normalizedFilter == MySkillFilter.ALL - ? skillRepository.findByOwnerId(userId, PageRequest.of(page, size)) - : filterSkillsByLifecycle(userId, page, size, normalizedFilter, platformRoles); + + Page skillPage; + if (normalizedFilter == MySkillFilter.ALL + && (keyword == null || keyword.isBlank()) + && (namespace == null || namespace.isBlank())) { + skillPage = skillRepository.findByOwnerId(userId, PageRequest.of(page, size)); + } else { + skillPage = filterSkills(userId, page, size, normalizedFilter, keyword, namespace, platformRoles); + } + List items = mySkillQueryRepository.getSkillSummaries(skillPage.getContent(), userId); return new PageResponse<>(items, skillPage.getTotalElements(), skillPage.getNumber(), skillPage.getSize()); @@ -118,15 +140,34 @@ public class MySkillAppService { return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); } - private Page filterSkillsByLifecycle(String userId, - int page, - int size, - MySkillFilter filter, - java.util.Set platformRoles) { + private Page filterSkills(String userId, + int page, + int size, + MySkillFilter filter, + String keyword, + String namespace, + java.util.Set platformRoles) { List skills = skillRepository.findByOwnerId(userId); + + // Namespace filter + Long namespaceId = null; + if (namespace != null && !namespace.isBlank()) { + namespaceId = namespaceRepository.findBySlug(namespace.trim()) + .map(Namespace::getId) + .orElse(-1L); + } + + final Long finalNamespaceId = namespaceId; + String normalizedKeyword = keyword != null && !keyword.isBlank() + ? keyword.trim().toLowerCase(java.util.Locale.ROOT) + : null; + List filtered = skills.stream() + .filter(skill -> matchesNamespace(skill, finalNamespaceId)) + .filter(skill -> matchesKeyword(skill, normalizedKeyword)) .filter(skill -> matchesFilter(skill, filter, platformRoles)) .toList(); + int fromIndex = Math.min(page * size, filtered.size()); int toIndex = Math.min(fromIndex + size, filtered.size()); return new PageImpl<>( @@ -136,6 +177,35 @@ public class MySkillAppService { ); } + private boolean matchesNamespace(Skill skill, Long namespaceId) { + if (namespaceId == null) { + return true; + } + if (namespaceId == -1L) { + return false; + } + return skill.getNamespaceId().equals(namespaceId); + } + + private boolean matchesKeyword(Skill skill, String keyword) { + if (keyword == null) { + return true; + } + String displayName = skill.getDisplayName() != null ? skill.getDisplayName().toLowerCase(java.util.Locale.ROOT) : ""; + String slug = skill.getSlug() != null ? skill.getSlug().toLowerCase(java.util.Locale.ROOT) : ""; + String summary = skill.getSummary() != null ? skill.getSummary().toLowerCase(java.util.Locale.ROOT) : ""; + + return displayName.contains(keyword) || slug.contains(keyword) || summary.contains(keyword); + } + + private Page filterSkillsByLifecycle(String userId, + int page, + int size, + MySkillFilter filter, + java.util.Set platformRoles) { + return filterSkills(userId, page, size, filter, null, null, platformRoles); + } + private boolean matchesFilter(Skill skill, MySkillFilter filter, java.util.Set platformRoles) { if (filter == MySkillFilter.HIDDEN) { return platformRoles.contains("SUPER_ADMIN") && skill.isHidden(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index c4faf136..92e45047 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -75,7 +75,8 @@ class MySkillAppServiceTest { skillStarRepository, skillSubscriptionRepository, mySkillQueryRepository, - skillLifecycleProjectionService + skillLifecycleProjectionService, + namespaceRepository ); } @@ -265,6 +266,107 @@ class MySkillAppServiceTest { assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("REJECTED"); } + @Test + void listMySkills_hidesStaleRejectedVersionOlderThanPublished() { + Skill skill = createSkill(6L, 101L, "recovered-skill", "user-1"); + SkillVersion rejectedVersion = createVersion(6L, 60L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion publishedVersion = createVersion(6L, 61L, "2.0.0", SkillVersionStatus.PUBLISHED, "2026-03-16T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); + given(skillVersionRepository.findBySkillId(6L)).willReturn(List.of(rejectedVersion, publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10); + + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).headlineVersion().status()).isEqualTo("PUBLISHED"); + assertThat(result.items().get(0).headlineVersion().version()).isEqualTo("2.0.0"); + assertThat(result.items().get(0).ownerPreviewVersion()).isNull(); + } + + @Test + void listMySkills_filtersByKeywordAcrossDisplayNameSlugAndSummary() { + Skill alpha = createSkill(1L, 101L, "alpha-tool", "user-1"); + alpha.setDisplayName("Alpha Assistant"); + Skill beta = createSkill(2L, 101L, "beta-tool", "user-1"); + beta.setDisplayName("Beta Tool"); + beta.setSummary("This tool helps with alpha testing"); + Skill gamma = createSkill(3L, 101L, "gamma-tool", "user-1"); + gamma.setDisplayName("Gamma Service"); + SkillVersion publishedVersion = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(alpha, beta, gamma)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(publishedVersion)); + given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of()); + given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of()); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, "alpha", null, Set.of("USER")); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.items()).extracting("slug") + .containsExactlyInAnyOrder("alpha-tool", "beta-tool"); + } + + @Test + void listMySkills_filtersByNamespaceSlug() { + Skill aiSkill = createSkill(1L, 101L, "ai-tool", "user-1"); + Skill mlSkill = createSkill(2L, 102L, "ml-tool", "user-1"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiSkill, mlSkill)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, null, null, "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-tool"); + } + + @Test + void listMySkills_returnsEmptyWhenNamespaceSlugNotFound() { + Skill skill = createSkill(1L, 101L, "ai-tool", "user-1"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(namespaceRepository.findBySlug("missing-namespace")).willReturn(java.util.Optional.empty()); + + var result = service.listMySkills("user-1", 0, 10, null, null, "missing-namespace", Set.of("USER")); + + assertThat(result.total()).isZero(); + assertThat(result.items()).isEmpty(); + } + + @Test + void listMySkills_combinesKeywordNamespaceAndStatusFilters() { + Skill aiAlpha = createSkill(1L, 101L, "ai-alpha", "user-1"); + aiAlpha.setDisplayName("AI Alpha"); + Skill aiBeta = createSkill(2L, 101L, "ai-beta", "user-1"); + aiBeta.setDisplayName("AI Beta"); + Skill mlAlpha = createSkill(3L, 102L, "ml-alpha", "user-1"); + mlAlpha.setDisplayName("ML Alpha"); + SkillVersion v1 = createVersion(1L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + SkillVersion v2 = createVersion(2L, 20L, "1.0.0", SkillVersionStatus.REJECTED, "2026-03-15T09:30:00Z"); + SkillVersion v3 = createVersion(3L, 30L, "1.0.0", SkillVersionStatus.PUBLISHED, "2026-03-15T09:30:00Z"); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(aiAlpha, aiBeta, mlAlpha)); + given(skillVersionRepository.findBySkillId(1L)).willReturn(List.of(v1)); + given(skillVersionRepository.findBySkillId(2L)).willReturn(List.of(v2)); + given(skillVersionRepository.findBySkillId(3L)).willReturn(List.of(v3)); + given(namespaceRepository.findBySlug("team-ai")).willReturn(java.util.Optional.of(namespace(101L, "team-ai"))); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace(101L, "team-ai"))); + + var result = service.listMySkills("user-1", 0, 10, "PUBLISHED", "alpha", "team-ai", Set.of("USER")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.items()).extracting("slug").containsExactly("ai-alpha"); + } + + private Skill createSkill(Long id, Long namespaceId, String slug, String ownerId) { Skill skill = new Skill(namespaceId, slug, ownerId, SkillVisibility.PUBLIC); skill.setDisplayName(slug); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java index 711ff60d..368ae850 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillLifecycleProjectionService.java @@ -39,6 +39,10 @@ public class SkillLifecycleProjectionService { ResolutionMode resolutionMode ) {} + private static final Comparator RECENCY = Comparator + .comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder())); + private final SkillVersionRepository skillVersionRepository; public SkillLifecycleProjectionService(SkillVersionRepository skillVersionRepository) { @@ -46,22 +50,26 @@ public class SkillLifecycleProjectionService { } public Projection projectForViewer(Skill skill, String currentUserId, Map userNsRoles) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveOwnerPendingPreview(skill, currentUserId, userNsRoles)); - VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; - return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = canManage(skill, currentUserId, userNsRoles) + ? resolveNewerNonPublishedVersion(skill, published) + : null; + return buildProjection(published, preview); } public Projection projectForOwnerSummary(Skill skill) { - VersionProjection publishedVersion = toProjection(resolvePublishedVersion(skill)); - VersionProjection ownerPreviewVersion = toProjection(resolveNewestNonPublishedVersion(skill)); + SkillVersion published = resolvePublishedVersion(skill); + SkillVersion preview = resolveNewerNonPublishedVersion(skill, published); + return buildProjection(published, preview); + } + + private Projection buildProjection(SkillVersion published, SkillVersion preview) { + VersionProjection publishedVersion = toProjection(published); + VersionProjection ownerPreviewVersion = toProjection(preview); VersionProjection headlineVersion = publishedVersion != null ? publishedVersion : ownerPreviewVersion; - ResolutionMode resolutionMode = headlineVersion == null - ? ResolutionMode.NONE - : publishedVersion != null ? ResolutionMode.PUBLISHED : ResolutionMode.OWNER_PREVIEW; + ResolutionMode resolutionMode = headlineVersion == null ? ResolutionMode.NONE + : publishedVersion != null ? ResolutionMode.PUBLISHED + : ResolutionMode.OWNER_PREVIEW; return new Projection(headlineVersion, publishedVersion, ownerPreviewVersion, resolutionMode); } @@ -116,27 +124,19 @@ public class SkillLifecycleProjectionService { } /** - * Returns the newest non-published version the owner can preview. - * Includes PENDING_REVIEW, REJECTED, DRAFT, SCANNING, SCAN_FAILED — any status - * that isn't already covered by the published projection and isn't yanked. + * Returns the newest non-published version (PENDING_REVIEW, REJECTED, DRAFT, SCANNING, + * SCAN_FAILED) that represents a NEW round of work layered on top of the current published + * version. A non-published version that is older than the published version is treated as + * settled history (e.g. an early rejected attempt later superseded by a published release) + * and is intentionally not surfaced, so the owner does not see a stale preview/rejected badge + * next to an already-published skill. */ - private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId, Map userNsRoles) { - if (!canManage(skill, currentUserId, userNsRoles)) { - return null; - } + private SkillVersion resolveNewerNonPublishedVersion(Skill skill, SkillVersion publishedVersion) { return skillVersionRepository.findBySkillId(skill.getId()).stream() - .filter(v -> v.getStatus() != SkillVersionStatus.PUBLISHED - && v.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) - .orElse(null); - } - - private SkillVersion resolveNewestNonPublishedVersion(Skill skill) { - List versions = skillVersionRepository.findBySkillId(skill.getId()); - return versions.stream() .filter(version -> version.getStatus() != SkillVersionStatus.PUBLISHED && version.getStatus() != SkillVersionStatus.YANKED) - .max(versionComparator()) + .filter(version -> publishedVersion == null || RECENCY.compare(version, publishedVersion) > 0) + .max(RECENCY) .orElse(null); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56a..16d2e7fe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1024,13 +1024,19 @@ export const governanceApi = { } export const meApi = { - async getSkills(params?: { page?: number; size?: number; filter?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + async getSkills(params?: { page?: number; size?: number; filter?: string; q?: string; namespace?: string }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { const searchParams = new URLSearchParams() searchParams.set('page', String(params?.page ?? 0)) searchParams.set('size', String(params?.size ?? 10)) if (params?.filter) { searchParams.set('filter', params.filter) } + if (params?.q) { + searchParams.set('q', params.q) + } + if (params?.namespace) { + searchParams.set('namespace', params.namespace) + } return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/skills?${searchParams.toString()}`) }, diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8cabf0b3..b053ca79 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -253,6 +253,12 @@ const dashboardSkillsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/skills', beforeLoad: requireAuth, + validateSearch: (search: Record): { page?: number; q?: string; namespace?: string; filter?: string } => ({ + page: typeof search.page === 'number' ? search.page : undefined, + q: typeof search.q === 'string' && search.q ? search.q : undefined, + namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace : undefined, + filter: typeof search.filter === 'string' && search.filter ? search.filter : undefined, + }), component: MySkillsPage, }) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3ff964a4..9c408cae 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -339,6 +339,12 @@ "mySkills": { "title": "My Skills", "subtitle": "Manage your published skills", + "searchPlaceholder": "Search by name, slug, or description", + "namespaceFilterLabel": "Filter by namespace", + "namespaceFilterAll": "All namespaces", + "clearSearch": "Clear filters", + "emptySearchTitle": "No matching skills", + "emptySearchDescription": "Try adjusting your keyword or switching namespace.", "filters": { "ALL": "All", "PENDING_REVIEW": "Pending Review", @@ -1277,7 +1283,8 @@ "prev": "Previous", "next": "Next", "pagePrefix": "Page", - "pageSuffix": "" + "pageSuffix": "", + "goToPage": "Go to page {{page}}" }, "user": { "menu": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 85cdc73b..b2940eda 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -339,6 +339,12 @@ "mySkills": { "title": "我的技能", "subtitle": "管理你发布的技能", + "searchPlaceholder": "搜索技能名称、Slug 或描述", + "namespaceFilterLabel": "按命名空间过滤", + "namespaceFilterAll": "全部命名空间", + "clearSearch": "清除筛选", + "emptySearchTitle": "未找到匹配的技能", + "emptySearchDescription": "试试调整关键字或切换命名空间", "filters": { "ALL": "全部", "PENDING_REVIEW": "待审核", @@ -1278,7 +1284,8 @@ "prev": "上一页", "next": "下一页", "pagePrefix": "第", - "pageSuffix": "页" + "pageSuffix": "页", + "goToPage": "第 {{page}} 页" }, "user": { "menu": { diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 78192c32..c272d107 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -1,22 +1,28 @@ -import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { useLocation, useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Pagination } from '@/shared/components/pagination' import { useArchiveSkill, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useMySkills, useSubmitPromotion } from '@/shared/hooks/use-user-queries' +import { useDebounce } from '@/shared/hooks/use-debounce' import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' +import { buildReturnTo } from '@/shared/lib/auth-route' import { ApiError } from '@/api/client' import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters' const PAGE_SIZE = 10 +const ALL_NAMESPACES_VALUE = '__all_namespaces__' /** * Dashboard page for skills owned by the current user. @@ -36,18 +42,62 @@ function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' export function MySkillsPage() { const navigate = useNavigate() + const location = useLocation() + const search = useSearch({ from: '/dashboard/skills' }) const { t } = useTranslation() const { hasRole } = useAuth() - const [page, setPage] = useState(0) - const [filter, setFilter] = useState('ALL') + + // The URL is the source of truth for page / filter / namespace / keyword so the + // search context survives navigating into a skill and back via the returnTo link. + const page = search.page ?? 0 + const filter = (search.filter as MySkillFilter) ?? 'ALL' + const namespaceFilter = search.namespace ?? '' + const keyword = search.q ?? '' + + // Keep an instant-feedback copy of the keyword input, debounced before it is + // pushed to the URL so each keystroke does not create a history entry or query. + const [keywordInput, setKeywordInput] = useState(keyword) + const debouncedKeyword = useDebounce(keywordInput.trim(), 300) + const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) - const { data: skillPage, isLoading } = useMySkills({ page, size: PAGE_SIZE, filter: filter === 'ALL' ? undefined : filter }) + + const updateSearch = (next: Partial, options?: { replace?: boolean }) => { + navigate({ + to: '/dashboard/skills', + search: (prev) => ({ ...prev, ...next }), + replace: options?.replace, + }) + } + + // Push the debounced keyword to the URL (reset page to 0 when search changes) + useEffect(() => { + if (debouncedKeyword !== keyword) { + updateSearch({ q: debouncedKeyword || undefined, page: 0 }, { replace: true }) + } + }, [debouncedKeyword]) + + // Sync keywordInput when navigating back via returnTo + useEffect(() => { + setKeywordInput(keyword) + }, [keyword]) + + const { data: skillPage, isLoading } = useMySkills({ + page, + size: PAGE_SIZE, + filter: filter === 'ALL' ? undefined : filter, + q: keyword || undefined, + namespace: namespaceFilter || undefined, + }) + const { data: namespacesPage } = useMyNamespaces({ page: 0, size: 100 }) + const namespaceOptions = namespacesPage?.items ?? [] + const skills = skillPage?.items ?? [] const totalPages = skillPage ? Math.max(Math.ceil(skillPage.total / skillPage.size), 1) : 1 const availableFilters = getMySkillFilters(hasRole('SUPER_ADMIN')) + const hasActiveSearch = keyword.trim() !== '' || namespaceFilter !== '' const emptyStateKey = getMySkillEmptyStateKey(filter) const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() @@ -57,10 +107,15 @@ export function MySkillsPage() { const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, - search: { returnTo: '/dashboard/skills' }, + search: { returnTo: buildReturnTo(location) }, }) } + const handleClearSearch = () => { + setKeywordInput('') + updateSearch({ q: undefined, namespace: undefined, page: 0 }) + } + const handleUpdateSkill = (namespace: string, visibility?: string) => { navigate({ to: '/dashboard/publish', @@ -238,21 +293,61 @@ export function MySkillsPage() { )} /> -
- {availableFilters.map((option) => ( - + ) : null} +
+ +
+ {availableFilters.map((option) => ( + + ))} +
{skillPage && skillPage.total > 0 ? ( @@ -400,17 +495,23 @@ export function MySkillsPage() { {skillPage.total > PAGE_SIZE ? ( - + updateSearch({ page: next })} /> ) : null} ) : ( navigate({ to: '/dashboard/publish' })}> - {t('mySkills.publishSkill')} - + hasActiveSearch ? ( + + ) : ( + + ) } /> )} diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 58c421db..a0865909 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -186,7 +186,7 @@ export function SearchPage() { } const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` }) + navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}`, search: { returnTo: `${window.location.pathname}${window.location.search}` } }) } const filteredStarredSkills = starredOnly diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 086f85a0..43c05b7f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -172,7 +172,6 @@ export function SkillDetailPage() { && ['PENDING_REVIEW', 'SCANNING', 'SCAN_FAILED'].includes(headlineVersion?.status ?? '') const hasPendingOwnerPreview = ownerPreviewVersion?.status === 'PENDING_REVIEW' const hasRejectedOwnerPreview = ownerPreviewVersion?.status === 'REJECTED' - const hasRejectedVersion = versions?.some((v) => v.status === 'REJECTED') ?? false const hasPublishedPendingReview = Boolean(publishedVersion && hasPendingOwnerPreview) const canInteract = skill?.canInteract ?? true const canReport = skill?.canReport ?? true @@ -762,7 +761,7 @@ export function SkillDetailPage() { {t('skillDetail.versionStatusPendingReview')} )} - {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview || hasRejectedVersion) && skill.canManageLifecycle && ( + {!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && ( {t('skillDetail.rejectedBadge')} diff --git a/web/src/shared/components/pagination.tsx b/web/src/shared/components/pagination.tsx index 5f54d904..069dbd85 100644 --- a/web/src/shared/components/pagination.tsx +++ b/web/src/shared/components/pagination.tsx @@ -7,8 +7,43 @@ interface PaginationProps { onPageChange: (page: number) => void } +type PageItem = number | 'ellipsis' + +/** + * Builds the list of page slots to render. Always shows the first and last page, + * the current page, and one neighbour on each side, collapsing the rest into + * ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed. + */ +function buildPageItems(current: number, totalPages: number): PageItem[] { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i) + } + + const items: PageItem[] = [] + const first = 0 + const last = totalPages - 1 + const start = Math.max(first + 1, current - 1) + const end = Math.min(last - 1, current + 1) + + items.push(first) + if (start > first + 1) { + items.push('ellipsis') + } + for (let i = start; i <= end; i += 1) { + items.push(i) + } + if (end < last - 1) { + items.push('ellipsis') + } + items.push(last) + + return items +} + export function Pagination({ page, totalPages, onPageChange }: PaginationProps) { const { t } = useTranslation() + const pageItems = buildPageItems(page, totalPages) + return (
-
- {t('pagination.pagePrefix')} - {page + 1} - / - {totalPages} - {t('pagination.pageSuffix') && {t('pagination.pageSuffix')}} + +
+ {pageItems.map((item, index) => + item === 'ellipsis' ? ( + + ) : ( + + ), + )}
+