Merge pull request #26 from iflytek/feature/project-local

Improve auth, search, publish, token, and dashboard UX
This commit is contained in:
yun-zhi-ztl 2026-03-14 05:11:00 -07:00 • committed by GitHub
commit 7218019aea
42 changed files with 1097 additions and 137 deletions

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.TokenCreateRequest;
import com.iflytek.skillhub.dto.TokenCreateResponse;
import com.iflytek.skillhub.dto.TokenSummaryResponse;
@ -45,17 +46,20 @@ public class TokenController extends BaseApiController {
}
@GetMapping
public ApiResponse<List<TokenSummaryResponse>> list(@AuthenticationPrincipal PlatformPrincipal principal) {
var tokens = apiTokenService.listActiveTokens(principal.userId());
var result = tokens.stream().map(t -> new TokenSummaryResponse(
public ApiResponse<PageResponse<TokenSummaryResponse>> list(
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size);
var result = tokens.map(t -> new TokenSummaryResponse(
t.getId(),
t.getName(),
t.getTokenPrefix(),
t.getCreatedAt().toString(),
t.getExpiresAt() != null ? t.getExpiresAt().toString() : "",
t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : ""
)).toList();
return ok("response.success.read", result);
));
return ok("response.success.read", PageResponse.from(result));
}
@DeleteMapping("/{id}")

View file

@ -4,10 +4,10 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LocalRegisterRequest(
@NotBlank(message = "用户名不能为空")
@NotBlank(message = "{validation.auth.local.username.notBlank}")
String username,
@NotBlank(message = "密码不能为空")
@NotBlank(message = "{validation.auth.local.password.notBlank}")
String password,
@Email(message = "邮箱格式不正确")
@Email(message = "{validation.auth.local.email.invalid}")
String email
) {}

View file

@ -1,11 +1,13 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TokenCreateRequest(
@NotBlank(message = "{validation.token.name.notBlank}")
@Size(max = 64, message = "{validation.token.name.size}")
String name,
List<String> scopes
) {}

View file

@ -1,16 +1,19 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ -27,39 +30,44 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(LocalizedException.class)
public ResponseEntity<ApiResponse<Void>> handleLocalizedError(LocalizedException ex) {
public ResponseEntity<ApiResponse<Void>> handleLocalizedError(LocalizedException ex, HttpServletRequest request) {
HttpStatus status = ex.status();
logHandledException(status, ex.messageCode(), request);
return ResponseEntity.status(status).body(
apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(AuthFlowException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex) {
public ResponseEntity<ApiResponse<Void>> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) {
HttpStatus status = ex.getStatus();
logHandledException(status, ex.getMessageCode(), request);
return ResponseEntity.status(status).body(
apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs()));
}
@ExceptionHandler(DomainBadRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex) {
public ResponseEntity<ApiResponse<Void>> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) {
logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request);
return ResponseEntity.badRequest().body(
apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(DomainForbiddenException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainForbidden(DomainForbiddenException ex) {
public ResponseEntity<ApiResponse<Void>> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) {
logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(DomainNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex) {
public ResponseEntity<ApiResponse<Void>> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) {
logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(FieldError::getDefaultMessage)
@ -67,6 +75,7 @@ public class GlobalExceptionHandler {
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse(null));
logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request);
if (msg == null || msg.isBlank()) {
return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest"));
}
@ -74,22 +83,52 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex) {
public ResponseEntity<ApiResponse<Void>> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) {
logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request);
return ResponseEntity.badRequest().body(
apiResponseFactory.error(400, "error.badRequest"));
}
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex) {
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex, HttpServletRequest request) {
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
apiResponseFactory.error(403, "error.forbidden"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex) {
String requestId = MDC.get("requestId");
logger.error("Unhandled exception [requestId={}]", requestId, ex);
public ResponseEntity<ApiResponse<Void>> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
resolveUserId(request),
ex
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
apiResponseFactory.error(500, "error.internal"));
}
private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
logger.info(
"API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]",
MDC.get("requestId"),
status.value(),
request.getMethod(),
request.getRequestURI(),
resolveUserId(request),
messageCode
);
}
private String resolveUserId(HttpServletRequest request) {
if (!(request.getUserPrincipal() instanceof Authentication authentication)) {
return "anonymous";
}
if (authentication.getPrincipal() instanceof PlatformPrincipal principal) {
return principal.userId();
}
return authentication.getName();
}
}

View file

@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
@ -15,6 +18,7 @@ import java.io.IOException;
@Component
public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
@ -27,6 +31,13 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
accessDeniedException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(403, "error.forbidden");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);

View file

@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
@ -15,6 +18,7 @@ import java.io.IOException;
@Component
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class);
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
@ -27,6 +31,13 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
logger.info(
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
request.getMethod(),
request.getRequestURI(),
authException.getClass().getSimpleName()
);
ApiResponse<Void> body = apiResponseFactory.error(401, "error.auth.required");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.data.domain.PageRequest;
@ -19,6 +20,7 @@ import java.util.stream.Collectors;
@Service
public class MySkillAppService {
private static final int STAR_PAGE_SIZE = 200;
private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
@ -68,10 +70,7 @@ public class MySkillAppService {
}
public List<SkillSummaryResponse> listMyStars(String userId) {
List<com.iflytek.skillhub.domain.social.SkillStar> stars = skillStarRepository.findByUserId(
userId,
PageRequest.of(0, 200)
).getContent();
List<com.iflytek.skillhub.domain.social.SkillStar> stars = loadAllStars(userId);
List<Long> skillIds = stars.stream()
.map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId)
@ -111,6 +110,24 @@ public class MySkillAppService {
.toList();
}
private List<com.iflytek.skillhub.domain.social.SkillStar> loadAllStars(String userId) {
List<com.iflytek.skillhub.domain.social.SkillStar> stars = new java.util.ArrayList<>();
int pageNumber = 0;
while (true) {
Page<com.iflytek.skillhub.domain.social.SkillStar> page = skillStarRepository.findByUserId(
userId,
PageRequest.of(pageNumber, STAR_PAGE_SIZE)
);
stars.addAll(page.getContent());
if (!page.hasNext()) {
return stars;
}
pageNumber++;
}
}
private SkillSummaryResponse toSummaryResponse(
Skill skill,
Map<Long, SkillVersion> versionsById,

View file

@ -0,0 +1,6 @@
ALTER TABLE api_token
ALTER COLUMN name TYPE VARCHAR(64);
CREATE UNIQUE INDEX uk_api_token_user_active_name
ON api_token (user_id, LOWER(name))
WHERE revoked_at IS NULL;

View file

@ -14,7 +14,12 @@ validation.namespace.displayName.size=Display name must not exceed 128 character
validation.namespace.description.size=Description must not exceed 512 characters
validation.member.userId.notNull=User ID is required
validation.member.role.notNull=Role is required
validation.auth.local.username.notBlank=Username cannot be blank
validation.auth.local.password.notBlank=Password cannot be blank
validation.auth.local.email.invalid=Email format is invalid
validation.token.name.notBlank=Token name cannot be blank
validation.token.name.size=Token name must be at most 64 characters
error.token.name.duplicate=You already have a token with this name
error.auth.required=Authentication required
error.auth.local.username.exists=Username already exists

View file

@ -14,7 +14,12 @@ validation.namespace.displayName.size=显示名称长度不能超过 128 个字
validation.namespace.description.size=描述长度不能超过 512 个字符
validation.member.userId.notNull=用户 ID 不能为空
validation.member.role.notNull=角色不能为空
validation.auth.local.username.notBlank=用户名不能为空
validation.auth.local.password.notBlank=密码不能为空
validation.auth.local.email.invalid=邮箱格式不正确
validation.token.name.notBlank=Token 名称不能为空
validation.token.name.size=Token 名称最多 64 个字符
error.token.name.duplicate=你已经有同名 Token
error.auth.required=需要先登录
error.auth.local.username.exists=用户名已存在

View file

@ -95,6 +95,23 @@ class LocalAuthControllerTest {
verify(skillHubMetrics).incrementUserRegister();
}
@Test
void register_rejectsInvalidEmailFormat() throws Exception {
given(localAuthService.register("bob", "Abcd123!", "not-an-email"))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"bob","password":"Abcd123!","email":"not-an-email"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("邮箱格式不正确"));
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
}
@Test
void login_failure_recordsFailureMetric() throws Exception {
given(localAuthService.login("alice", "wrong"))

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -18,11 +19,18 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -61,4 +69,63 @@ class TokenControllerTest {
verify(apiTokenService).revokeToken(7L, "user-42");
}
@Test
void create_rejectsNamesLongerThan64Characters() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
given(apiTokenService.createToken(anyString(), anyString(), anyString()))
.willThrow(new DomainBadRequestException("validation.token.name.size"));
mockMvc.perform(post("/api/v1/tokens")
.with(authentication(auth))
.with(csrf())
.contentType("application/json")
.content("""
{"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符"));
}
@Test
void list_returns_paginated_tokens() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "tester", "tester@example.com", "", "github", Set.of("USER")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
var tokenPage = new PageImpl<>(
List.of(
new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"),
new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]")
),
PageRequest.of(1, 10),
12
);
var first = tokenPage.getContent().get(0);
var second = tokenPage.getContent().get(1);
org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L);
org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0));
org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L);
org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0));
given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage);
mockMvc.perform(get("/api/v1/tokens")
.with(authentication(auth))
.param("page", "1")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].name").value("cli"))
.andExpect(jsonPath("$.data.items[1].name").value("deploy"))
.andExpect(jsonPath("$.data.total").value(12))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(10));
}
}

View file

@ -0,0 +1,89 @@
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.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.social.SkillStar;
import com.iflytek.skillhub.domain.social.SkillStarRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
class MySkillAppServiceTest {
@Mock
private SkillRepository skillRepository;
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private SkillVersionRepository skillVersionRepository;
@Mock
private SkillStarRepository skillStarRepository;
private MySkillAppService service;
@BeforeEach
void setUp() {
service = new MySkillAppService(skillRepository, namespaceRepository, skillVersionRepository, skillStarRepository);
}
@Test
void listMyStars_loadsAllPagesOfStars() {
SkillStar firstStar = new SkillStar(1L, "user-1");
ReflectionTestUtils.setField(firstStar, "createdAt", LocalDateTime.of(2026, 3, 14, 10, 0));
SkillStar secondStar = new SkillStar(2L, "user-1");
ReflectionTestUtils.setField(secondStar, "createdAt", LocalDateTime.of(2026, 3, 14, 11, 0));
given(skillStarRepository.findByUserId("user-1", PageRequest.of(0, 200)))
.willReturn(new PageImpl<>(List.of(firstStar), PageRequest.of(0, 200), 201));
given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 200)))
.willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 200), 201));
Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC);
firstSkill.setDisplayName("First Skill");
firstSkill.setSummary("first summary");
ReflectionTestUtils.setField(firstSkill, "id", 1L);
ReflectionTestUtils.setField(firstSkill, "starCount", 1);
ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L);
ReflectionTestUtils.setField(firstSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 10, 0));
Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC);
secondSkill.setDisplayName("Second Skill");
secondSkill.setSummary("second summary");
ReflectionTestUtils.setField(secondSkill, "id", 2L);
ReflectionTestUtils.setField(secondSkill, "starCount", 2);
ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L);
ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0));
given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill));
given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1")));
var stars = service.listMyStars("user-1");
assertThat(stars).hasSize(2);
assertThat(stars.get(0).slug()).isEqualTo("second-skill");
assertThat(stars.get(1).slug()).isEqualTo("first-skill");
}
}

View file

@ -21,7 +21,7 @@ public class ApiToken {
@Column(name = "user_id", nullable = false)
private String userId;
@Column(nullable = false, length = 128)
@Column(nullable = false, length = 64)
private String name;
@Column(name = "token_prefix", nullable = false, length = 16)

View file

@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
public class LocalAuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$");
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
private static final int MAX_FAILED_ATTEMPTS = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
@ -57,6 +58,7 @@ public class LocalAuthService {
}
String normalizedEmail = normalizeEmail(email);
validateEmail(normalizedEmail);
if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists");
}
@ -190,4 +192,13 @@ public class LocalAuthService {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid");
}
}
private void validateEmail(String email) {
if (email == null) {
return;
}
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid");
}
}
}

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.auth.repository;
import com.iflytek.skillhub.auth.entity.ApiToken;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@ -11,4 +13,6 @@ public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
Optional<ApiToken> findByTokenHash(String tokenHash);
List<ApiToken> findByUserId(String userId);
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
Page<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable);
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
}

View file

@ -2,6 +2,10 @@ package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -20,6 +24,7 @@ public class ApiTokenService {
private static final String TOKEN_PREFIX = "sk_";
private static final int TOKEN_BYTES = 32;
private static final int MAX_NAME_LENGTH = 64;
private final SecureRandom secureRandom = new SecureRandom();
private final ApiTokenRepository tokenRepo;
@ -31,14 +36,21 @@ public class ApiTokenService {
@Transactional
public TokenCreateResult createToken(String userId, String name, String scopeJson) {
String normalizedName = normalizeName(name);
validateTokenName(userId, normalizedName);
byte[] randomBytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(randomBytes);
String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
String tokenHash = sha256(rawToken);
String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8));
ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson);
token = tokenRepo.save(token);
ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson);
try {
token = tokenRepo.save(token);
} catch (DataIntegrityViolationException ex) {
throw new DomainBadRequestException("error.token.name.duplicate");
}
return new TokenCreateResult(rawToken, token);
}
@ -61,6 +73,12 @@ public class ApiTokenService {
return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId);
}
public Page<ApiToken> listActiveTokens(String userId, int page, int size) {
int resolvedPage = Math.max(page, 0);
int resolvedSize = Math.max(size, 1);
return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize));
}
@Transactional
public void touchLastUsed(ApiToken token) {
token.setLastUsedAt(LocalDateTime.now());
@ -76,4 +94,23 @@ public class ApiTokenService {
throw new RuntimeException("SHA-256 not available", e);
}
}
private String normalizeName(String name) {
if (name == null) {
return "";
}
return name.trim();
}
private void validateTokenName(String userId, String name) {
if (name.isBlank()) {
throw new DomainBadRequestException("validation.token.name.notBlank");
}
if (name.length() > MAX_NAME_LENGTH) {
throw new DomainBadRequestException("validation.token.name.size");
}
if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) {
throw new DomainBadRequestException("error.token.name.duplicate");
}
}
}

View file

@ -131,4 +131,13 @@ class LocalAuthServiceTest {
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("error.auth.local.accountDisabled");
}
@Test
void register_rejectsInvalidEmailFormat() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
assertThatThrownBy(() -> service.register("Alice", "Abcd123!", "not-an-email"))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("validation.auth.local.email.invalid");
}
}

View file

@ -0,0 +1,65 @@
package com.iflytek.skillhub.auth.token;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ApiTokenServiceTest {
@Mock
private ApiTokenRepository tokenRepo;
private ApiTokenService service;
@BeforeEach
void setUp() {
service = new ApiTokenService(tokenRepo);
}
@Test
void createToken_rejectsNamesLongerThan64Characters() {
String longName = "a".repeat(65);
assertThatThrownBy(() -> service.createToken("user-1", longName, "[]"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("validation.token.name.size");
verify(tokenRepo, never()).save(any());
}
@Test
void createToken_rejectsDuplicateActiveNamesIgnoringCase() {
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"))
.thenReturn(true);
assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]"))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageContaining("error.token.name.duplicate");
verify(tokenRepo, never()).save(any());
}
@Test
void createToken_trimsNameBeforeCheckingDuplicates() {
when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"))
.thenReturn(true);
assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]"))
.isInstanceOf(DomainBadRequestException.class);
verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token");
verify(tokenRepo, never()).save(any());
}
}

View file

@ -27,5 +27,10 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -12,6 +12,7 @@ import java.util.Set;
@Service
public class PostgresFullTextQueryService implements SearchQueryService {
private static final int SHORT_KEYWORD_LENGTH = 2;
private final EntityManager entityManager;
@ -21,6 +22,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
@Override
public SearchResult search(SearchQuery query) {
String normalizedKeyword = normalizeKeyword(query.keyword());
boolean hasKeyword = normalizedKeyword != null;
boolean useShortKeywordFallback = hasKeyword && normalizedKeyword.length() <= SHORT_KEYWORD_LENGTH;
Set<Long> memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().memberNamespaceIds();
@ -48,8 +52,17 @@ public class PostgresFullTextQueryService implements SearchQueryService {
}
// Full-text search
if (query.keyword() != null && !query.keyword().isBlank()) {
sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) ");
if (hasKeyword) {
if (useShortKeywordFallback) {
sql.append("AND (");
sql.append("LOWER(title) LIKE LOWER(:keywordLike) ");
sql.append("OR LOWER(summary) LIKE LOWER(:keywordLike) ");
sql.append("OR LOWER(keywords) LIKE LOWER(:keywordLike) ");
sql.append("OR LOWER(search_text) LIKE LOWER(:keywordLike)");
sql.append(") ");
} else {
sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) ");
}
}
// Sorting
@ -59,7 +72,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC ");
} else if ("newest".equals(query.sortBy())) {
sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC ");
} else if ("relevance".equals(query.sortBy()) && query.keyword() != null && !query.keyword().isBlank()) {
} else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) {
sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC ");
} else {
sql.append("ORDER BY updated_at DESC ");
@ -80,8 +93,12 @@ public class PostgresFullTextQueryService implements SearchQueryService {
nativeQuery.setParameter("namespaceId", query.namespaceId());
}
if (query.keyword() != null && !query.keyword().isBlank()) {
nativeQuery.setParameter("keyword", query.keyword());
if (hasKeyword) {
if (useShortKeywordFallback) {
nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%");
} else {
nativeQuery.setParameter("keyword", normalizedKeyword);
}
}
nativeQuery.setParameter("limit", query.size());
@ -115,12 +132,23 @@ public class PostgresFullTextQueryService implements SearchQueryService {
countQuery.setParameter("namespaceId", query.namespaceId());
}
if (query.keyword() != null && !query.keyword().isBlank()) {
countQuery.setParameter("keyword", query.keyword());
if (hasKeyword) {
if (useShortKeywordFallback) {
countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%");
} else {
countQuery.setParameter("keyword", normalizedKeyword);
}
}
long total = ((Number) countQuery.getSingleResult()).longValue();
return new SearchResult(skillIds, total, query.page(), query.size());
}
private String normalizeKeyword(String keyword) {
if (keyword == null || keyword.isBlank()) {
return null;
}
return keyword.trim();
}
}

View file

@ -0,0 +1,110 @@
package com.iflytek.skillhub.search.postgres;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchVisibilityScope;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PostgresFullTextQueryServiceTest {
@Test
void shortKeywordsShouldUseLikeFallback() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
when(entityManager.createNativeQuery(anyString()))
.thenReturn(nativeQuery)
.thenReturn(countQuery);
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
when(nativeQuery.getResultList()).thenReturn(List.of(1L));
when(countQuery.getSingleResult()).thenReturn(1L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
service.search(new SearchQuery(
"ai",
null,
new SearchVisibilityScope(null, Set.of(), Set.of()),
"relevance",
0,
20
));
verify(nativeQuery).setParameter("keywordLike", "%ai%");
verify(countQuery).setParameter("keywordLike", "%ai%");
verify(nativeQuery, never()).setParameter("keyword", "ai");
verify(countQuery, never()).setParameter("keyword", "ai");
}
@Test
void longerKeywordsShouldKeepFullTextSearch() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
when(entityManager.createNativeQuery(anyString()))
.thenReturn(nativeQuery)
.thenReturn(countQuery);
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
when(nativeQuery.getResultList()).thenReturn(List.of(1L));
when(countQuery.getSingleResult()).thenReturn(1L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
service.search(new SearchQuery(
"agent",
null,
new SearchVisibilityScope(null, Set.of(), Set.of()),
"relevance",
0,
20
));
verify(nativeQuery).setParameter("keyword", "agent");
verify(countQuery).setParameter("keyword", "agent");
verify(nativeQuery, never()).setParameter("keywordLike", "%agent%");
verify(countQuery, never()).setParameter("keywordLike", "%agent%");
}
@Test
void shortKeywordSqlShouldAvoidTsRankOrdering() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
when(entityManager.createNativeQuery(anyString()))
.thenReturn(nativeQuery)
.thenReturn(countQuery);
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
when(nativeQuery.getResultList()).thenReturn(List.of());
when(countQuery.getSingleResult()).thenReturn(0L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
service.search(new SearchQuery(
"go",
null,
new SearchVisibilityScope(null, Set.of(), Set.of()),
"relevance",
0,
20
));
var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE LOWER(:keywordLike)");
assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank");
}
}

View file

@ -170,15 +170,54 @@ type ApiEnvelope<T> = {
requestId: string
}
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
type RequestWithTimeout = RequestInit & {
timeoutMs?: number
}
function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } {
if (!init?.timeoutMs && !init?.signal) {
return { signal: init?.signal ?? undefined, cleanup: () => {} }
}
const controller = new AbortController()
const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined
const abortListener = () => controller.abort()
if (init?.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', abortListener, { once: true })
}
}
return {
signal: controller.signal,
cleanup: () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId)
}
init?.signal?.removeEventListener('abort', abortListener)
},
}
}
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestWithTimeout): Promise<T> {
const { signal, cleanup } = createRequestSignal(init)
let response: Response
try {
response = await fetch(withBaseUrl(input), {
...init,
signal,
headers: withRequestHeaders(init?.headers),
})
} catch {
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new ApiError('error.request.timeout', 408)
}
throw new ApiError('Network error', 0)
} finally {
cleanup()
}
let json: ApiEnvelope<T> | null = null
@ -372,19 +411,28 @@ export const accountApi = {
}
export const tokenApi = {
async getTokens(): Promise<ApiToken[]> {
const tokens = await unwrap<ApiToken[]>(client.GET('/api/v1/tokens', {
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {
params: {
query: {
page: params?.page ?? 0,
size: params?.size ?? 10,
},
},
headers: withRequestHeaders(),
} as never) as never)
return tokens
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
.map((token) => ({
...token,
id: token.id!,
name: token.name!,
tokenPrefix: token.tokenPrefix!,
createdAt: token.createdAt!,
}))
return {
...page,
items: page.items
.filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt)
.map((token) => ({
...token,
id: token.id!,
name: token.name!,
tokenPrefix: token.tokenPrefix!,
createdAt: token.createdAt!,
})),
}
},
async createToken(request: CreateTokenRequest): Promise<CreateTokenResponse> {
@ -408,14 +456,23 @@ export const tokenApi = {
},
async deleteToken(tokenId: number): Promise<void> {
await unwrap(client.DELETE('/api/v1/tokens/{id}', {
const { error, response } = await client.DELETE('/api/v1/tokens/{id}', {
params: {
path: {
id: tokenId,
},
},
headers: withCsrf(),
}) as never)
} as never)
if (response.status === 204) {
return
}
const envelope = (error && isApiEnvelope<void>(error) ? error : null) as { msg?: string } | null
if (!response.ok || error) {
throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg)
}
},
}

View file

@ -19,12 +19,18 @@ const queryClient = new QueryClient({
},
},
queryCache: new QueryCache({
onError: (error) => {
onError: (error, query) => {
if (query.meta?.skipGlobalErrorHandler) {
return
}
handleApiError(error)
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.meta?.skipGlobalErrorHandler) {
return
}
// Only auto-handle if the mutation doesn't have its own onError
if (!mutation.options.onError) {
handleApiError(error)

View file

@ -1,22 +1,39 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
interface SearchBarProps {
defaultValue?: string
value?: string
placeholder?: string
onChange?: (query: string) => void
onSearch?: (query: string) => void
}
export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBarProps) {
export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) {
const { t } = useTranslation()
const [query, setQuery] = useState(defaultValue)
const isControlled = value !== undefined
const currentQuery = isControlled ? value : query
useEffect(() => {
if (!isControlled) {
setQuery(defaultValue)
}
}, [defaultValue, isControlled])
const handleChange = (nextQuery: string) => {
if (!isControlled) {
setQuery(nextQuery)
}
onChange?.(nextQuery)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (onSearch) {
onSearch(query)
onSearch(currentQuery)
}
}
@ -38,8 +55,8 @@ export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBa
</svg>
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
value={currentQuery}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder || t('searchBar.placeholder')}
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
/>

View file

@ -9,13 +9,48 @@ interface MarkdownRendererProps {
}
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
const containerClassName = [className, 'prose prose-sm max-w-none dark:prose-invert']
const containerClassName = [
className,
'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert',
]
.filter(Boolean)
.join(' ')
return (
<div className={containerClassName}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize, rehypeHighlight]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize, rehypeHighlight]}
components={{
pre: ({ children }) => (
<div className="max-w-full overflow-x-auto rounded-lg bg-muted/40 p-4">
<pre className="m-0 min-w-max bg-transparent p-0">{children}</pre>
</div>
),
code: ({ className: codeClassName, children, ...props }) => {
const isInline = !codeClassName?.includes('language-')
if (isInline) {
return (
<code className="break-words rounded bg-muted px-1 py-0.5 text-sm" {...props}>
{children}
</code>
)
}
return (
<code className={codeClassName} {...props}>
{children}
</code>
)
},
table: ({ children }) => (
<div className="max-w-full overflow-x-auto">
<table>{children}</table>
</div>
),
}}
>
{content}
</ReactMarkdown>
</div>

View file

@ -10,13 +10,13 @@ interface SkillCardProps {
export function SkillCard({ skill, onClick }: SkillCardProps) {
return (
<Card
className="p-5 cursor-pointer group relative overflow-hidden"
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">
<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}
@ -30,7 +30,7 @@ export function SkillCard({ skill, onClick }: SkillCardProps) {
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<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}

View file

@ -47,6 +47,7 @@ export function useToggleStar(skillId: number) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] })
queryClient.invalidateQueries({ queryKey: ['skills'] })
queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] })
},
})
}

View file

@ -19,33 +19,57 @@ import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types'
interface CreateTokenDialogProps {
children: React.ReactNode
existingNames?: string[]
}
export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
const MAX_TOKEN_NAME_LENGTH = 64
export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null)
const [nameError, setNameError] = useState<string | null>(null)
const queryClient = useQueryClient()
const normalizedName = name.trim()
const hasDuplicateName = existingNames.some(
(existingName) => existingName.trim().toLocaleLowerCase() === normalizedName.toLocaleLowerCase()
)
const createMutation = useMutation({
mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request),
onSuccess: (data) => {
setCreatedToken(data)
setName('')
setNameError(null)
queryClient.invalidateQueries({ queryKey: ['tokens'] })
},
})
const handleCreate = () => {
if (!name.trim()) return
createMutation.mutate({ name: name.trim() })
if (!normalizedName) {
setNameError(t('createToken.nameRequired'))
return
}
if (normalizedName.length > MAX_TOKEN_NAME_LENGTH) {
setNameError(t('createToken.nameTooLong', { max: MAX_TOKEN_NAME_LENGTH }))
return
}
if (hasDuplicateName) {
setNameError(t('createToken.nameDuplicate'))
return
}
setNameError(null)
createMutation.mutate({ name: normalizedName })
}
const handleClose = () => {
setOpen(false)
setCreatedToken(null)
setName('')
setNameError(null)
createMutation.reset()
}
@ -54,10 +78,22 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
try {
await navigator.clipboard.writeText(createdToken.token)
toast.success(t('createToken.copySuccess'))
toast.success(t('createToken.copySuccess'), undefined, {
position: 'top-center',
classNames: {
title: 'text-center font-semibold',
description: 'text-center',
},
})
} catch (error) {
console.error('Failed to copy token:', error)
toast.error(t('createToken.copyFailed'))
toast.error(t('createToken.copyFailed'), undefined, {
position: 'top-center',
classNames: {
title: 'text-center font-semibold',
description: 'text-center',
},
})
}
}
@ -80,22 +116,40 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) {
id="token-name"
placeholder={t('createToken.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={MAX_TOKEN_NAME_LENGTH}
onChange={(e) => {
setName(e.target.value)
if (nameError) {
setNameError(null)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreate()
}
}}
aria-invalid={nameError || hasDuplicateName ? 'true' : 'false'}
/>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="text-red-600">
{nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')}
</span>
<span className="text-muted-foreground">
{normalizedName.length}/{MAX_TOKEN_NAME_LENGTH}
</span>
</div>
</div>
</div>
{createMutation.error ? (
<p className="text-sm text-red-600">{createMutation.error.message}</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
{t('dialog.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={!name.trim() || createMutation.isPending}
disabled={!normalizedName || hasDuplicateName || createMutation.isPending}
>
{createMutation.isPending ? t('createToken.creating') : t('createToken.create')}
</Button>

View file

@ -13,28 +13,67 @@ import {
} from '@/shared/ui/table'
import { CreateTokenDialog } from './create-token-dialog'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { Pagination } from '@/shared/components/pagination'
import { toast } from '@/shared/lib/toast'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import type { ApiToken } from '@/api/types'
const PAGE_SIZE = 10
type TokenPage = { items: ApiToken[]; total: number; page: number; size: number }
export function TokenList() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(0)
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({
open: false,
})
const { data: tokens, isLoading } = useQuery<ApiToken[]>({
queryKey: ['tokens'],
queryFn: tokenApi.getTokens,
const { data: tokenPage, isLoading, isError, error } = useQuery<TokenPage>({
queryKey: ['tokens', page, PAGE_SIZE],
queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }),
meta: {
skipGlobalErrorHandler: true,
},
})
const deleteMutation = useMutation({
mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId),
onMutate: async (tokenId) => {
await queryClient.cancelQueries({ queryKey: ['tokens'] })
const previousPages = queryClient.getQueriesData<TokenPage>({ queryKey: ['tokens'] })
queryClient.setQueriesData<TokenPage>({ queryKey: ['tokens'] }, (current) => {
if (!current) {
return current
}
const nextItems = current.items.filter((token) => token.id !== tokenId)
if (nextItems.length === current.items.length) {
return current
}
return {
...current,
items: nextItems,
total: Math.max(current.total - 1, 0),
}
})
return { previousPages }
},
onSuccess: () => {
if (tokenPage && tokenPage.items.length === 1 && page > 0) {
setPage(page - 1)
}
setDeleteDialog({ open: false })
queryClient.invalidateQueries({ queryKey: ['tokens'] })
toast.success(t('token.deleteSuccess'))
},
onError: () => {
onError: (_error, _tokenId, context) => {
context?.previousPages.forEach(([queryKey, previousPage]) => {
queryClient.setQueryData(queryKey, previousPage)
})
toast.error(t('token.deleteFailed'))
},
})
@ -45,29 +84,48 @@ export function TokenList() {
const confirmDelete = async () => {
if (deleteDialog.tokenId) {
deleteMutation.mutate(deleteDialog.tokenId)
await deleteMutation.mutateAsync(deleteDialog.tokenId)
}
}
const formatDate = (dateString?: string | null) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
return formatLocalDateTime(dateString, i18n.language)
}
const tokens = tokenPage?.items ?? []
const totalPages = tokenPage ? Math.max(Math.ceil(tokenPage.total / tokenPage.size), 1) : 1
if (isLoading) {
return <div className="text-center py-8 text-muted-foreground">{t('token.loading')}</div>
}
if (isError) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
<CreateTokenDialog existingNames={[]}>
<Button>{t('token.createNew')}</Button>
</CreateTokenDialog>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
{error instanceof Error ? error.message : t('apiError.unknown')}
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
<CreateTokenDialog>
<CreateTokenDialog existingNames={tokens.map((token) => token.name)}>
<Button>{t('token.createNew')}</Button>
</CreateTokenDialog>
</div>
{!tokens || tokens.length === 0 ? (
{!tokenPage || tokenPage.total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>{t('token.empty')}</p>
<p className="text-sm mt-2">{t('token.emptyHint')}</p>
@ -114,6 +172,10 @@ export function TokenList() {
</div>
)}
{tokenPage && tokenPage.total > PAGE_SIZE ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
<ConfirmDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}

View file

@ -111,6 +111,10 @@
"password": "Password",
"usernamePlaceholder": "Enter username",
"passwordPlaceholder": "Enter password",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submitting": "Logging in...",
"submit": "Login",
"noAccount": "Don't have an account?",
@ -322,6 +326,7 @@
"emptyDescription": "No skills have been published in this namespace yet"
},
"skillDetail": {
"back": "Back",
"notFound": "Skill not found",
"notFoundDesc": "This skill may have been deleted or never existed",
"loginRequired": "Login Required",
@ -393,6 +398,9 @@
"description": "Create a new API Token for CLI or API access",
"nameLabel": "Token Name",
"namePlaceholder": "e.g.: my-cli-token",
"nameRequired": "Token name is required",
"nameTooLong": "Token name must be at most {{max}} characters",
"nameDuplicate": "You already have a token with this name",
"creating": "Creating...",
"create": "Create",
"successTitle": "Token Created",
@ -462,6 +470,10 @@
"success": "Published Successfully",
"successDescription": "{{skill}} has been submitted for review and will be available after admin approval",
"error": "Publish Failed",
"timeoutTitle": "Publish timed out",
"timeoutDescription": "The publish request took too long. Please check the skill list later or try again.",
"versionExistsTitle": "Version already exists",
"versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.",
"selectRequired": "Please select namespace and file"
},
"toast": {

View file

@ -111,6 +111,10 @@
"password": "密码",
"usernamePlaceholder": "输入用户名",
"passwordPlaceholder": "输入密码",
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"submitting": "登录中...",
"submit": "登录",
"noAccount": "还没有账号?",
@ -322,6 +326,7 @@
"emptyDescription": "该命名空间下还没有发布任何技能"
},
"skillDetail": {
"back": "返回上一页",
"notFound": "技能不存在",
"notFoundDesc": "该技能可能已被删除或从未存在",
"loginRequired": "需要登录",
@ -393,6 +398,9 @@
"description": "创建一个新的 API Token 用于 CLI 或 API 访问",
"nameLabel": "Token 名称",
"namePlaceholder": "例如: my-cli-token",
"nameRequired": "请输入 Token 名称",
"nameTooLong": "Token 名称最多 {{max}} 个字符",
"nameDuplicate": "你已经有同名 Token",
"creating": "创建中...",
"create": "创建",
"successTitle": "Token 创建成功",
@ -462,6 +470,10 @@
"success": "发布成功",
"successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用",
"error": "发布失败",
"timeoutTitle": "发布请求超时",
"timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。",
"versionExistsTitle": "版本号已存在",
"versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。",
"selectRequired": "请选择命名空间和文件"
},
"toast": {

View file

@ -1,11 +1,8 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './app/providers'
import './i18n/config'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
<App />,
)

View file

@ -8,6 +8,17 @@ import { Label } from '@/shared/ui/label'
import { Card } from '@/shared/ui/card'
import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries'
import { toast } from '@/shared/lib/toast'
import { ApiError } from '@/api/client'
function isVersionExistsMessage(message?: string): boolean {
if (!message) {
return false
}
return message.includes('error.skill.version.exists')
|| message.includes('Version already exists')
|| message.includes('版本已存在')
}
export function PublishPage() {
const { t } = useTranslation()
@ -39,6 +50,19 @@ export function PublishPage() {
)
navigate({ to: '/dashboard/skills' })
} catch (error) {
if (error instanceof ApiError && error.status === 408) {
toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription'))
return
}
if (error instanceof ApiError && isVersionExistsMessage(error.serverMessage || error.message)) {
toast.error(
t('publish.versionExistsTitle'),
t('publish.versionExistsDescription'),
)
return
}
toast.error(t('publish.error'), error instanceof Error ? error.message : '')
}
}
@ -113,7 +137,7 @@ export function PublishPage() {
</div>
<Button
className="w-full"
className="w-full text-primary-foreground disabled:text-primary-foreground"
size="lg"
onClick={handlePublish}
disabled={!selectedFile || !namespaceSlug || publishMutation.isPending}
@ -123,4 +147,4 @@ export function PublishPage() {
</Card>
</div>
)
}
}

View file

@ -1,6 +1,7 @@
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Eye, EyeOff } from 'lucide-react'
import { getDirectAuthRuntimeConfig } from '@/api/client'
import { LoginButton } from '@/features/auth/login-button'
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
@ -18,6 +19,8 @@ export function LoginPage() {
const directAuthConfig = getDirectAuthRuntimeConfig()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({})
const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh'
const { data: authMethods } = useAuthMethods(search.returnTo)
@ -30,8 +33,23 @@ export function LoginPage() {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const trimmedUsername = username.trim()
const nextFieldErrors: { username?: string, password?: string } = {}
if (!trimmedUsername) {
nextFieldErrors.username = t('login.usernameRequired')
}
if (!password) {
nextFieldErrors.password = t('login.passwordRequired')
}
if (nextFieldErrors.username || nextFieldErrors.password) {
setFieldErrors(nextFieldErrors)
return
}
setFieldErrors({})
try {
await loginMutation.mutateAsync({ username, password })
await loginMutation.mutateAsync({ username: trimmedUsername, password })
await navigate({ to: returnTo })
} catch {
// mutation state drives the error UI
@ -79,20 +97,50 @@ export function LoginPage() {
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
onChange={(event) => {
setUsername(event.target.value)
if (fieldErrors.username) {
setFieldErrors((current) => ({ ...current, username: undefined }))
}
}}
placeholder={t('login.usernamePlaceholder')}
aria-invalid={fieldErrors.username ? 'true' : 'false'}
/>
{fieldErrors.username ? (
<p className="text-sm text-red-600">{fieldErrors.username}</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password">{t('login.password')}</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={t('login.passwordPlaceholder')}
/>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(event) => {
setPassword(event.target.value)
if (fieldErrors.password) {
setFieldErrors((current) => ({ ...current, password: undefined }))
}
}}
placeholder={t('login.passwordPlaceholder')}
className="pr-12"
aria-invalid={fieldErrors.password ? 'true' : 'false'}
/>
<button
type="button"
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
aria-pressed={showPassword}
onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{fieldErrors.password ? (
<p className="text-sm text-red-600">{fieldErrors.password}</p>
) : null}
</div>
{loginMutation.error ? (
<p className="text-sm text-red-600">{loginMutation.error.message}</p>

View file

@ -1,3 +1,4 @@
import { startTransition, useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SearchBar } from '@/features/search/search-bar'
@ -16,6 +17,11 @@ export function SearchPage() {
const q = searchParams.q || ''
const sort = searchParams.sort || 'relevance'
const page = searchParams.page ?? 0
const [queryInput, setQueryInput] = useState(q)
useEffect(() => {
setQueryInput(q)
}, [q])
const { data, isLoading } = useSearchSkills({
q,
@ -24,8 +30,34 @@ export function SearchPage() {
size: 12,
})
useEffect(() => {
const normalizedQuery = queryInput.trim()
if (normalizedQuery === q) {
return
}
if (!normalizedQuery) {
startTransition(() => {
navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 })
})
return
}
const timeoutId = window.setTimeout(() => {
startTransition(() => {
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
})
}, 250)
return () => window.clearTimeout(timeoutId)
}, [navigate, page, q, queryInput, sort])
const handleSearch = (query: string) => {
navigate({ to: '/search', search: { q: query, sort, page: 0 } })
const normalizedQuery = query.trim()
setQueryInput(query)
startTransition(() => {
navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true })
})
}
const handleSortChange = (newSort: string) => {
@ -46,7 +78,7 @@ export function SearchPage() {
<div className="space-y-8 animate-fade-up">
{/* Search Bar */}
<div className="max-w-3xl mx-auto">
<SearchBar defaultValue={q} onSearch={handleSearch} />
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
</div>
{/* Sort Selector */}
@ -92,7 +124,7 @@ export function SearchPage() {
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{data.items.map((skill, idx) => (
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
<div key={skill.id} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
<SkillCard
skill={skill}
onClick={() => handleSkillClick(skill.namespace, skill.slug)}

View file

@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate, useRouterState } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { FileTree } from '@/features/skill/file-tree'
import { InstallCommand } from '@/features/skill/install-command'
@ -8,6 +9,7 @@ import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi } from '@/api/client'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -77,6 +79,14 @@ export function SkillDetailPage() {
})
}
const handleBack = () => {
if (window.history.length > 1) {
window.history.back()
return
}
navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } })
}
if (isLoadingSkill) {
return (
<div className="space-y-6 animate-fade-up">
@ -122,6 +132,15 @@ export function SkillDetailPage() {
{/* Main Content */}
<div className="lg:col-span-2 space-y-8">
<div className="space-y-3">
<Button
variant="ghost"
size="sm"
className="gap-2 px-0 text-muted-foreground hover:text-foreground"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4" />
{t('skillDetail.back')}
</Button>
<div className="flex items-center gap-3 mb-1">
<NamespaceBadge type="GLOBAL" name={namespace} />
</div>
@ -173,7 +192,7 @@ export function SkillDetailPage() {
</span>
</span>
<span className="text-sm text-muted-foreground">
{new Date(version.publishedAt).toLocaleDateString(i18n.language)}
{formatLocalDateTime(version.publishedAt, i18n.language)}
</span>
</div>
{version.changelog && (

View file

@ -26,7 +26,9 @@ export function EmptyState({ title, description, action }: EmptyStateProps) {
</div>
<h3 className="text-lg font-semibold font-heading text-foreground mb-2">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-md mb-6">{description}</p>
<p className="text-sm text-muted-foreground max-w-md mb-6 break-words [overflow-wrap:anywhere]">
{description}
</p>
)}
{action && <div>{action}</div>}
</div>

View file

@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
async function searchSkills(params: SearchParams): Promise<PagedResponse<SkillSummary>> {
const queryParams = new URLSearchParams()
if (params.q) queryParams.append('q', params.q)
@ -75,6 +77,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility:
method: 'POST',
headers: getCsrfHeaders(),
body: formData,
timeoutMs: PUBLISH_REQUEST_TIMEOUT_MS,
})
}

View file

@ -0,0 +1,21 @@
function parseServerDateTime(value: string): Date {
if (/[zZ]$|[+-]\d{2}:\d{2}$/.test(value)) {
return new Date(value)
}
const [datePart, timePart = '00:00:00'] = value.split('T')
const [year, month, day] = datePart.split('-').map(Number)
const [rawTime, fractional = ''] = timePart.split('.')
const [hours = 0, minutes = 0, seconds = 0] = rawTime.split(':').map(Number)
const milliseconds = Number((fractional + '000').slice(0, 3))
return new Date(year, (month || 1) - 1, day || 1, hours, minutes, seconds, milliseconds)
}
export function formatLocalDateTime(
value: string,
locale: string,
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
) {
return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value))
}

View file

@ -1,17 +1,17 @@
import { toast as sonnerToast } from 'sonner'
import { toast as sonnerToast, type ExternalToast } from 'sonner'
export const toast = {
success: (message: string, description?: string) => {
sonnerToast.success(message, { description })
success: (message: string, description?: string, options?: ExternalToast) => {
sonnerToast.success(message, { description, ...options })
},
error: (message: string, description?: string) => {
sonnerToast.error(message, { description })
error: (message: string, description?: string, options?: ExternalToast) => {
sonnerToast.error(message, { description, ...options })
},
warning: (message: string, description?: string) => {
sonnerToast.warning(message, { description })
warning: (message: string, description?: string, options?: ExternalToast) => {
sonnerToast.warning(message, { description, ...options })
},
info: (message: string, description?: string) => {
sonnerToast.info(message, { description })
info: (message: string, description?: string, options?: ExternalToast) => {
sonnerToast.info(message, { description, ...options })
},
promise: <T,>(
promise: Promise<T>,

View file

@ -1,4 +1,5 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/shared/lib/utils'
interface DialogContextValue {
@ -27,6 +28,20 @@ const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) =
const open = controlledOpen ?? uncontrolledOpen
const handleOpenChange = onOpenChange ?? setUncontrolledOpen
React.useEffect(() => {
if (!open || typeof document === 'undefined') {
return undefined
}
const { body } = document
const previousOverflow = body.style.overflow
body.style.overflow = 'hidden'
return () => {
body.style.overflow = previousOverflow
}
}, [open])
return (
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
{children}
@ -65,7 +80,10 @@ DialogTrigger.displayName = 'DialogTrigger'
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
const { open } = useDialog()
if (!open) return null
return <>{children}</>
if (typeof document === 'undefined') {
return null
}
return createPortal(children, document.body)
}
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
@ -92,39 +110,37 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
return (
<DialogPortal>
<DialogOverlay />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
ref={ref}
className={cn(
'relative z-50 grid w-full max-w-lg gap-4 border border-border/60 bg-card p-8 shadow-card rounded-2xl animate-fade-up',
className
)}
onClick={(e) => e.stopPropagation()}
{...props}
<div
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card animate-fade-up',
className
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</DialogPortal>
)