diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 059da89b..b2ad113d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -71,7 +71,9 @@ public class SkillController extends BaseApiController { detail.hidden(), detail.latestVersion(), namespace, - detail.canManageLifecycle() + detail.canManageLifecycle(), + detail.viewingVersionStatus(), + detail.canInteract() ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 09a67b51..f7152d7b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -16,5 +16,7 @@ public record SkillDetailResponse( boolean hidden, String latestVersion, String namespace, - boolean canManageLifecycle + boolean canManageLifecycle, + String viewingVersionStatus, + boolean canInteract ) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 5022d199..d34698f0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -102,6 +102,42 @@ class SkillControllerTest { .andExpect(jsonPath("$.requestId").isNotEmpty()); } + @Test + void getSkillDetailShouldExposePendingPreviewFlags() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillDetailDTO( + 1L, + "demo", + "Demo", + "Pending preview", + "PUBLIC", + "ACTIVE", + 10L, + 2, + null, + 0, + false, + "1.1.0", + 1L, + LocalDateTime.of(2026, 3, 15, 10, 0), + LocalDateTime.of(2026, 3, 15, 10, 0), + null, + true, + "PENDING_REVIEW", + false + )); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.latestVersion").value("1.1.0")) + .andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW")) + .andExpect(jsonPath("$.data.canInteract").value(false)); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index a371940d..d03565f5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -13,6 +13,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -69,7 +70,9 @@ public class SkillQueryService { java.time.LocalDateTime createdAt, java.time.LocalDateTime updatedAt, SkillVersion latestVersionEntity, - boolean canManageLifecycle + boolean canManageLifecycle, + String viewingVersionStatus, + boolean canInteract ) {} public record SkillVersionDetailDTO( @@ -111,12 +114,9 @@ public class SkillQueryService { } String latestVersion = null; - SkillVersion latestVersionEntity = null; - if (skill.getLatestVersionId() != null) { - latestVersionEntity = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); - if (latestVersionEntity != null) { - latestVersion = latestVersionEntity.getVersion(); - } + SkillVersion latestVersionEntity = resolvePreviewVersion(skill, currentUserId); + if (latestVersionEntity != null) { + latestVersion = latestVersionEntity.getVersion(); } return new SkillDetailDTO( @@ -136,7 +136,9 @@ public class SkillQueryService { skill.getCreatedAt(), skill.getUpdatedAt(), latestVersionEntity, - canManageRestrictedSkill(skill, currentUserId, userNsRoles) + canManageRestrictedSkill(skill, currentUserId, userNsRoles), + latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null, + latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED ); } @@ -171,7 +173,7 @@ public class SkillQueryService { Skill skill = findSkill(namespaceSlug, skillSlug); assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); return new SkillVersionDetailDTO( skillVersion.getId(), @@ -196,7 +198,7 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); return skillFileRepository.findByVersionId(skillVersion.getId()); } @@ -224,11 +226,11 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); - assertPublishedVersion(skillVersion, version); + assertPreviewAccessible(skill, skillVersion, version, currentUserId); SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } public InputStream getFileContentByTag( @@ -242,7 +244,7 @@ public class SkillQueryService { assertPublishedAccessible(skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); - return objectStorageService.getObject(file.getStorageKey()); + return readFileContent(file); } public Page listVersions(String namespaceSlug, @@ -338,6 +340,14 @@ public class SkillQueryService { .orElseThrow(() -> new DomainBadRequestException("error.skill.file.notFound", filePath)); } + private InputStream readFileContent(SkillFile file) { + try { + return objectStorageService.getObject(file.getStorageKey()); + } catch (UncheckedIOException e) { + throw new DomainBadRequestException("error.skill.file.notFound", file.getFilePath()); + } + } + private SkillVersion resolveVersionEntity(Skill skill, String version, String tag, String hash) { if (version != null && !version.isBlank()) { SkillVersion exactVersion = findVersion(skill, version); @@ -388,6 +398,28 @@ public class SkillQueryService { return latestVersion; } + private SkillVersion resolvePreviewVersion(Skill skill, String currentUserId) { + SkillVersion publishedVersion = null; + if (skill.getLatestVersionId() != null) { + publishedVersion = skillVersionRepository.findById(skill.getLatestVersionId()).orElse(null); + } + if (publishedVersion != null) { + return publishedVersion; + } + return resolveOwnerPendingPreview(skill, currentUserId); + } + + private SkillVersion resolveOwnerPendingPreview(Skill skill, String currentUserId) { + if (!isOwner(skill, currentUserId)) { + return null; + } + return skillVersionRepository.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW).stream() + .max(Comparator + .comparing(SkillVersion::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(SkillVersion::getId, Comparator.nullsLast(Comparator.naturalOrder()))) + .orElse(null); + } + private String computeFingerprint(SkillVersion version) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -430,6 +462,10 @@ public class SkillQueryService { || role == NamespaceRole.OWNER; } + private boolean isOwner(Skill skill, String currentUserId) { + return currentUserId != null && skill.getOwnerId().equals(currentUserId); + } + private int lifecycleListPriority(SkillVersionStatus status) { if (status == SkillVersionStatus.PUBLISHED) { return 0; @@ -454,4 +490,14 @@ public class SkillQueryService { throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); } } + + private void assertPreviewAccessible(Skill skill, SkillVersion version, String versionStr, String currentUserId) { + if (version.getStatus() == SkillVersionStatus.PUBLISHED) { + return; + } + if (version.getStatus() == SkillVersionStatus.PENDING_REVIEW && isOwner(skill, currentUserId)) { + return; + } + throw new DomainBadRequestException("error.skill.version.notPublished", versionStr); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 2dfbaba6..143e8b7a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.UncheckedIOException; import java.lang.reflect.Field; import java.util.List; import java.util.Map; @@ -202,6 +203,39 @@ class SkillQueryServiceTest { service.listFiles(namespaceSlug, skillSlug, version, "user-100", userNsRoles)); } + @Test + void testGetFileContent_ShouldTranslateMissingStorageObject() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.0.0"; + String filePath = "SKILL.md"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-100", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion skillVersion = new SkillVersion(1L, version, "user-100"); + setId(skillVersion, 1L); + skillVersion.setStatus(SkillVersionStatus.PUBLISHED); + SkillFile file = new SkillFile(1L, filePath, 100L, "text/markdown", "hash1", "skills/1/1/SKILL.md"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion)); + when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file)); + when(objectStorageService.getObject(file.getStorageKey())) + .thenThrow(new UncheckedIOException(new java.io.FileNotFoundException(file.getStorageKey()))); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> + service.getFileContent(namespaceSlug, skillSlug, version, filePath, "user-100", userNsRoles)); + + assertEquals("error.skill.file.notFound", ex.messageCode()); + assertArrayEquals(new Object[]{filePath}, ex.messageArgs()); + } + @Test void testGetVersionDetail_ShouldReturnMetadataPayload() throws Exception { String namespaceSlug = "test-ns"; @@ -432,6 +466,170 @@ class SkillQueryServiceTest { assertFalse(result.canManageLifecycle()); } + @Test + void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0)); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(pending)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertEquals("1.1.0", result.latestVersion()); + assertEquals("PENDING_REVIEW", result.viewingVersionStatus()); + assertFalse(result.canInteract()); + } + + @Test + void testGetSkillDetail_ShouldKeepPublishedVersionWhenSkillAlreadyPublic() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); + setId(pending, 12L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertEquals("1.0.0", result.latestVersion()); + assertEquals("PUBLISHED", result.viewingVersionStatus()); + assertTrue(result.canInteract()); + } + + @Test + void testGetVersionDetail_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + pending.setParsedMetadataJson("{\"name\":\"test-skill\"}"); + pending.setManifestJson("[{\"path\":\"SKILL.md\"}]"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail( + namespaceSlug, + skillSlug, + version, + ownerId, + userNsRoles + ); + + assertEquals("PENDING_REVIEW", result.status()); + assertEquals("{\"name\":\"test-skill\"}", result.parsedMetadataJson()); + } + + @Test + void testListFiles_ShouldAllowPendingVersionForOwnerPreview() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + SkillVersion pending = new SkillVersion(1L, version, ownerId); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + SkillFile file = new SkillFile(11L, "README.md", 12L, "text/markdown", "hash", "storage-key"); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file)); + + List result = service.listFiles(namespaceSlug, skillSlug, version, ownerId, userNsRoles); + + assertEquals(1, result.size()); + assertEquals("README.md", result.get(0).getFilePath()); + } + + @Test + void testGetVersionDetail_ShouldRejectPendingVersionForNonOwner() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String version = "1.1.0"; + String viewerId = "viewer-1"; + Map userNsRoles = Map.of(1L, NamespaceRole.MEMBER); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1"); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + SkillVersion pending = new SkillVersion(1L, version, "owner-1"); + setId(pending, 11L); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending)); + + assertThrows(DomainBadRequestException.class, () -> + service.getVersionDetail(namespaceSlug, skillSlug, version, viewerId, userNsRoles)); + } + @Test void testListVersions_ShouldIncludeDraftAndRejectedForLifecycleManagers() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 36a203eb..8c59a289 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -133,6 +133,8 @@ export interface SkillDetail { latestVersion?: string namespace: string canManageLifecycle: boolean + viewingVersionStatus?: string + canInteract: boolean } export interface SkillVersion { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0568cb92..3ba8c57e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -384,6 +384,7 @@ "tabFiles": "Files", "tabVersions": "Versions", "noReadme": "No README", + "readmeUnavailable": "README is temporarily unavailable. Please try again later or re-upload this skill version.", "noFiles": "No files", "noVersions": "No versions", "fileCount": "{{count}} files", @@ -402,6 +403,10 @@ "statusActive": "Active", "statusArchived": "Archived", "statusHidden": "Hidden", + "pendingPreviewBadge": "Pending Preview", + "pendingPreviewTitle": "You are previewing a pending version", + "pendingPreviewDescription": "This version is only visible to you. Before review approval, you can inspect the README, files, and version information, but you cannot star, rate, report, or download it.", + "pendingPreviewInteractionHint": "Stars, ratings, and reports are disabled while this version is pending review.", "governance": "Governance", "processing": "Processing...", "archiveSkill": "Archive Skill", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 34e840b8..78e9b37b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -384,6 +384,7 @@ "tabFiles": "文件", "tabVersions": "版本", "noReadme": "暂无 README", + "readmeUnavailable": "README 暂时不可用,请稍后重试或重新上传该技能版本。", "noFiles": "暂无文件", "noVersions": "暂无版本", "fileCount": "{{count}} 个文件", @@ -402,6 +403,10 @@ "statusActive": "正常", "statusArchived": "已归档", "statusHidden": "已隐藏", + "pendingPreviewBadge": "待审核预览", + "pendingPreviewTitle": "当前正在预览待审核版本", + "pendingPreviewDescription": "该版本仅你本人可见。审核通过前,你可以查看 README、文件和版本信息,但不能收藏、评分、举报或下载。", + "pendingPreviewInteractionHint": "待审核预览期间不可收藏、评分或举报。", "governance": "治理操作", "processing": "处理中...", "archiveSkill": "归档技能", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index a82d0c82..7794ffad 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -77,9 +77,10 @@ export function SkillDetailPage() { const { data: skill, isLoading: isLoadingSkill, error: skillError } = useSkillDetail(namespace, slug) const { data: versions } = useSkillVersions(namespace, slug) - const latestVersion = versions?.[0] - const { data: files } = useSkillFiles(namespace, slug, latestVersion?.version) - const { data: readme } = useSkillReadme(namespace, slug, latestVersion?.version) + const selectedVersion = skill?.latestVersion ?? versions?.[0]?.version + const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] + const { data: files } = useSkillFiles(namespace, slug, selectedVersion) + const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion) const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined) const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined) const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined) @@ -87,6 +88,8 @@ export function SkillDetailPage() { const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined) const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined) const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + const isPendingPreview = skill?.viewingVersionStatus === 'PENDING_REVIEW' + const canInteract = skill?.canInteract ?? true const refreshSkill = () => { queryClient.invalidateQueries({ queryKey: ['skills', namespace, slug] }) @@ -105,7 +108,7 @@ export function SkillDetailPage() { }) const yankMutation = useMutation({ - mutationFn: () => adminApi.yankVersion(latestVersion!.id), + mutationFn: () => adminApi.yankVersion(selectedVersionEntry!.id), onSuccess: refreshSkill, }) const archiveMutation = useArchiveSkill() @@ -120,11 +123,11 @@ export function SkillDetailPage() { requireLogin() return } - if (!latestVersion) { + if (!selectedVersionEntry || isPendingPreview) { return } const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${latestVersion.version}/download` + const downloadUrl = `${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${selectedVersionEntry.version}/download` window.open(downloadUrl, '_blank') } @@ -389,11 +392,22 @@ export function SkillDetailPage() { {resolveSkillStatusLabel(skill.status)} )} + {isPendingPreview && ( + + {t('skillDetail.pendingPreviewBadge')} + + )}

{skill.displayName}

{skill.summary && (

{skill.summary}

)} + {isPendingPreview && ( + +
{t('skillDetail.pendingPreviewTitle')}
+

{t('skillDetail.pendingPreviewDescription')}

+
+ )} @@ -408,6 +422,10 @@ export function SkillDetailPage() { + ) : readmeError ? ( + + {t('skillDetail.readmeUnavailable')} + ) : ( {t('skillDetail.noReadme')} @@ -544,18 +562,24 @@ export function SkillDetailPage() {
- - - - {!user && ( + {canInteract ? ( + <> + + + + + ) : ( +

{t('skillDetail.pendingPreviewInteractionHint')}

+ )} + {!user && canInteract && (

{t('skillDetail.loginToRate')}

)}
- {skill.latestVersion && ( + {skill.latestVersion && canInteract && (
{t('skillDetail.install')}
{skill.status === 'ARCHIVED' && ( @@ -574,7 +598,7 @@ export function SkillDetailPage() { variant="outline" size="lg" onClick={handleDownload} - disabled={!latestVersion || skill.status === 'ARCHIVED'} + disabled={!selectedVersionEntry || skill.status === 'ARCHIVED' || isPendingPreview} > @@ -615,7 +639,7 @@ export function SkillDetailPage() { {unhideMutation.isPending ? t('skillDetail.processing') : t('skillDetail.unhideSkill')} )} - {latestVersion && ( + {selectedVersionEntry && ( diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index ce2bf1b5..7b177fa0 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -44,7 +44,7 @@ async function getSkillReadme(namespace: string, slug: string, version: string): try { return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=SKILL.md`) } catch { - return '' + return await fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/file?path=README.md`) } } diff --git a/web/src/shared/lib/date-time.ts b/web/src/shared/lib/date-time.ts index ff229610..64c3e457 100644 --- a/web/src/shared/lib/date-time.ts +++ b/web/src/shared/lib/date-time.ts @@ -13,9 +13,12 @@ function parseServerDateTime(value: string): Date { } export function formatLocalDateTime( - value: string, + value: string | null | undefined, locale: string, options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }, ) { + if (!value) { + return '—' + } return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value)) }