mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
feat(web): unify landing, dashboard, and paginated lists (#825)
* feat(web): unify landing and dashboard experience
Closes #824
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): clamp skill card summaries
Keep skill grids compact by reserving a stable three-line summary region while exposing the full description via the title attribute.
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* test(web): align e2e with redesigned experience
Update real-service E2E assertions for the current landing and dashboard flows, and make settings card headings distinct from their page headings.
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* Revert "test(web): align e2e with redesigned experience"
This reverts commit 3f78115277.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): align dashboard layout footer spacing
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): restore footer access links
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): link footer API to Swagger UI
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): refine footer resource links
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): link landing CTA to open source resources
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(frontend): restore responsive navigation contracts
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): wrap narrow search controls
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* test(web): derive landing guide origin
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): remove landing statistics strip
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* test(web): align landing guide assertion
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): address follow-up review feedback
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(web): update landing CLI version
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
3fd8c63fe5
commit
beecc34b88
70 changed files with 2597 additions and 1016 deletions
|
|
@ -76,6 +76,15 @@ public class NamespaceController extends BaseApiController {
|
|||
namespacePortalQueryAppService.listMyNamespaces(userNsRoles, platformRoles(principal)));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces/page")
|
||||
public ApiResponse<PageResponse<MyNamespaceResponse>> listMyNamespacesPage(
|
||||
Pageable pageable,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.listMyNamespacesPage(pageable, userNsRoles, platformRoles(principal)));
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,26 @@ public class NamespacePortalQueryAppService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<MyNamespaceResponse> listMyNamespacesPage(Pageable pageable,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
if (namespaceRoles.isEmpty()) {
|
||||
Page<MyNamespaceResponse> empty = new PageImpl<>(List.of(), pageable, 0);
|
||||
return PageResponse.from(empty);
|
||||
}
|
||||
|
||||
Pageable sortedPageable = pageable.isPaged()
|
||||
? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
||||
org.springframework.data.domain.Sort.by("slug").ascending())
|
||||
: PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("slug").ascending());
|
||||
Page<MyNamespaceResponse> page = namespaceRepository
|
||||
.findByIdIn(namespaceRoles.keySet().stream().toList(), sortedPageable)
|
||||
.map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId())));
|
||||
return PageResponse.from(page);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug,
|
||||
String userId,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
|
@ -95,6 +96,26 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.data[0].canDelete").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMyNamespacesPage_limitsResultsAndReturnsTotal() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceRepository.findByIdIn(eq(List.of(1L)), any(org.springframework.data.domain.Pageable.class)))
|
||||
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(namespace), PageRequest.of(0, 10), 1));
|
||||
given(namespaceMemberRepository.findByUserId("owner-1"))
|
||||
.willReturn(List.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me/namespaces/page")
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.items[0].slug").value("team-a"))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.size").value(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() throws Exception {
|
||||
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public interface NamespaceRepository {
|
|||
Optional<Namespace> findById(Long id);
|
||||
List<Namespace> findAll();
|
||||
List<Namespace> findByIdIn(List<Long> ids);
|
||||
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
|
||||
Optional<Namespace> findBySlug(String slug);
|
||||
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
|
||||
Namespace save(Namespace namespace);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import java.util.Optional;
|
|||
public interface NamespaceJpaRepository
|
||||
extends JpaRepository<Namespace, Long>, NamespaceRepository {
|
||||
List<Namespace> findByIdIn(List<Long> ids);
|
||||
Page<Namespace> findByIdIn(List<Long> ids, Pageable pageable);
|
||||
Optional<Namespace> findBySlug(String slug);
|
||||
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ test.describe('Dashboard Shell (Real API)', () => {
|
|||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('renders account summary and quick links', async ({ page }) => {
|
||||
test('renders account navigation and overview links', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
||||
await expect(page.getByText('Account Information')).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'View API Tokens' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'View My Skills' }).first()).toBeVisible()
|
||||
const sidebar = page.getByRole('complementary')
|
||||
await expect(sidebar.getByRole('link', { name: 'Profile', exact: true })).toBeVisible()
|
||||
await expect(sidebar.getByRole('link', { name: 'My Skills', exact: true })).toBeVisible()
|
||||
await expect(sidebar.getByRole('link', { name: 'API Tokens', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('View and manage all your published skills')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ test.describe('Landing Navigation (Real API)', () => {
|
|||
test('submits the hero search to the search page', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Discover & Share AI Skills' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Turn team expertise into Agent-ready skills' })).toBeVisible()
|
||||
|
||||
const searchInput = page.getByPlaceholder('Search skills...')
|
||||
await searchInput.fill('agent ops')
|
||||
|
|
|
|||
|
|
@ -1,51 +1,56 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
test.describe('Landing Quick Start CLI Tab (Real API)', () => {
|
||||
test.describe('Landing access methods (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('renders three peer tabs and exposes the CLI install command', async ({ page }) => {
|
||||
test('renders three access methods and exposes current CLI commands', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const agentTab = page.getByRole('button', { name: 'I am Agent', exact: true })
|
||||
const humanTab = page.getByRole('button', { name: 'I am Human', exact: true })
|
||||
const cliTab = page.getByRole('button', { name: 'CLI', exact: true })
|
||||
const agentMode = page.getByRole('button', { name: /Agent integration/ })
|
||||
const cliMode = page.getByRole('button', { name: /\bCLI\b/ })
|
||||
const webMode = page.getByRole('button', { name: /Web interface/ })
|
||||
|
||||
await expect(agentTab).toBeVisible()
|
||||
await expect(humanTab).toBeVisible()
|
||||
await expect(cliTab).toBeVisible()
|
||||
await expect(agentMode).toBeVisible({ timeout: 15_000 })
|
||||
await expect(cliMode).toBeVisible()
|
||||
await expect(webMode).toBeVisible()
|
||||
await expect(agentMode).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await expect(agentTab).toHaveAttribute('aria-pressed', 'true')
|
||||
await cliMode.click()
|
||||
await expect(cliMode).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(agentMode).toHaveAttribute('aria-pressed', 'false')
|
||||
await expect(webMode).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await cliTab.click()
|
||||
await expect(cliTab).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(agentTab).toHaveAttribute('aria-pressed', 'false')
|
||||
await expect(humanTab).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await expect(
|
||||
page.getByText('Install the SkillHub CLI locally to run skillhub install for skills.'),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('npm i -g @astron-team/skillhub', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('npx -y @astron-team/skillhub@0.1.12 --version', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/npx -y @astron-team\/skillhub@0\.1\.12 search weather/)).toBeVisible()
|
||||
await expect(page.getByText(/npx -y @astron-team\/skillhub@0\.1\.12 install @global\/weather/)).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'CLI docs' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/iflytek/skillhub/tree/main/cli',
|
||||
)
|
||||
})
|
||||
|
||||
test('agent and human tabs expose the current SkillHub guidance', async ({ page }) => {
|
||||
test('agent views expose Registry configuration and implicit discovery', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const agentTab = page.getByRole('button', { name: 'I am Agent', exact: true })
|
||||
const humanTab = page.getByRole('button', { name: 'I am Human', exact: true })
|
||||
const registryTab = page.getByRole('tab', { name: 'Registry setup' })
|
||||
const discoveryTab = page.getByRole('tab', { name: 'Implicit discovery' })
|
||||
|
||||
await expect(registryTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(page.getByText(/registry\/skill\.md/).first()).toBeVisible()
|
||||
|
||||
await discoveryTab.click()
|
||||
await expect(discoveryTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(page.getByText('Search SkillHub Registry')).toBeVisible()
|
||||
await expect(page.getByText('Match @global/weather · v1.3.0')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Connect SkillHub using http://127.0.0.1:3000/registry/skill.md',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
const guideResponse = await page.request.get('/registry/skill.md')
|
||||
expect(guideResponse.status()).toBe(200)
|
||||
const guide = await guideResponse.text()
|
||||
expect(guide).toContain('http://127.0.0.1:3000')
|
||||
expect(guide).toContain('name: skillhub-cli')
|
||||
expect(guide).toContain('removing the trailing `/registry/skill.md`')
|
||||
expect(guideResponse.headers()['cache-control']).toContain('no-cache')
|
||||
const hostileHostResponse = await page.request.get('/registry/skill.md', {
|
||||
headers: { Host: 'attacker.example' },
|
||||
|
|
@ -55,23 +60,5 @@ test.describe('Landing Quick Start CLI Tab (Real API)', () => {
|
|||
headers: { Host: 'chrome-extension:evil;echo_injected' },
|
||||
})
|
||||
expect(extensionHostResponse.status()).toBe(400)
|
||||
|
||||
await humanTab.click()
|
||||
await expect(humanTab).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(
|
||||
page.getByText(
|
||||
'npx @astron-team/skillhub@latest search <keyword> --registry http://127.0.0.1:3000',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
|
||||
await agentTab.click()
|
||||
await expect(agentTab).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Connect SkillHub using http://127.0.0.1:3000/registry/skill.md',
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ test.describe('Settings Pages (Real API)', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('navigates to reset-password page from profile settings', async ({ page }) => {
|
||||
test('navigates to security settings from profile settings', async ({ page }) => {
|
||||
await page.goto('/settings/profile')
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click()
|
||||
await expect(page).toHaveURL('/reset-password')
|
||||
await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible()
|
||||
await expect(page).toHaveURL('/settings/security')
|
||||
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows validation when current password is missing', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ test.describe('Light and dark theme', () => {
|
|||
await page.reload()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Turn team expertise into Agent-ready skills' })).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => (
|
||||
window as Window & { __themeAtFirstReactContent?: boolean }
|
||||
).__themeAtFirstReactContent)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ test.describe('User ID Display', () => {
|
|||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('shows user ID in dashboard account card', async ({ page }) => {
|
||||
test('shows user ID in the dashboard sidebar', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByText('Account Information')).toBeVisible()
|
||||
|
||||
const userIdText = page.getByText('User ID', { exact: false })
|
||||
const userIdText = page.getByRole('complementary').getByText('User ID', { exact: false })
|
||||
await expect(userIdText).toBeVisible()
|
||||
|
||||
// The dashboard renders "User ID: <value>" in a single element.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skillhub-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.18",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -660,6 +660,13 @@ export const namespaceApi = {
|
|||
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
|
||||
},
|
||||
|
||||
async listMinePage(params?: { page?: number; size?: number }): Promise<PagedResponse<ManagedNamespace>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('page', String(params?.page ?? 0))
|
||||
searchParams.set('size', String(params?.size ?? 10))
|
||||
return fetchJson<PagedResponse<ManagedNamespace>>(`${WEB_API_PREFIX}/me/namespaces/page?${searchParams.toString()}`)
|
||||
},
|
||||
|
||||
async getDetail(slug: string): Promise<Namespace> {
|
||||
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
|
||||
},
|
||||
|
|
|
|||
98
web/src/api/generated/schema.d.ts
vendored
98
web/src/api/generated/schema.d.ts
vendored
|
|
@ -2964,6 +2964,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/namespaces/page": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyNamespacesPage"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/me/namespaces/page": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listMyNamespacesPage_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/namespaces": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -5093,11 +5125,11 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListMyNamespaceResponse: {
|
||||
ApiResponsePageResponseMyNamespaceResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["MyNamespaceResponse"][];
|
||||
data?: components["schemas"]["PageResponseMyNamespaceResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
|
|
@ -5127,6 +5159,24 @@ export interface components {
|
|||
canRestore?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
PageResponseMyNamespaceResponse: {
|
||||
items?: components["schemas"]["MyNamespaceResponse"][];
|
||||
/** Format: int64 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListMyNamespaceResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["MyNamespaceResponse"][];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ApiResponseGovernanceSummaryResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -11240,6 +11290,50 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listMyNamespacesPage: {
|
||||
parameters: {
|
||||
query: {
|
||||
pageable: components["schemas"]["Pageable"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMyNamespacesPage_1: {
|
||||
parameters: {
|
||||
query: {
|
||||
pageable: components["schemas"]["Pageable"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMyNamespaces: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ describe('getAppHeaderClassName', () => {
|
|||
const className = getAppHeaderClassName(false)
|
||||
|
||||
expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME)
|
||||
expect(className).toContain('bg-background/90')
|
||||
expect(className).toContain('bg-background/70')
|
||||
expect(className).not.toContain('bg-white')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export const APP_HEADER_BASE_CLASS_NAME =
|
||||
'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12'
|
||||
'sticky top-0 z-50 flex items-center justify-between border-b border-border/30 bg-background/70 px-4 py-2.5 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-150 supports-[backdrop-filter]:bg-background/60 sm:px-6 md:px-12 min-h-[52px]'
|
||||
|
||||
export const APP_HEADER_ELEVATED_CLASS_NAME =
|
||||
'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]'
|
||||
'border-b border-border/30 shadow-[0_1px_2px_0_rgb(0_0_0/0.04)]'
|
||||
|
||||
export function getAppHeaderClassName(isElevated: boolean): string {
|
||||
return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('getAppMainContentLayout', () => {
|
|||
mainClassName: CENTERED_MAIN_CLASS_NAME,
|
||||
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
|
||||
})
|
||||
expect(layout.contentClassName).toContain('max-w-[1200px]')
|
||||
expect(layout.contentClassName).toContain('max-w-[1100px]')
|
||||
})
|
||||
|
||||
it('leaves other non-landing routes on the default full-width app content layout', () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10'
|
||||
export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12'
|
||||
export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20'
|
||||
export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10'
|
||||
export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
|
||||
export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]'
|
||||
export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]'
|
||||
|
||||
export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const
|
||||
|
||||
interface AppMainContentLayout {
|
||||
mainClassName: string
|
||||
|
|
@ -31,7 +33,7 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout
|
|||
}
|
||||
}
|
||||
|
||||
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) {
|
||||
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) {
|
||||
return {
|
||||
mainClassName: CENTERED_MAIN_CLASS_NAME,
|
||||
contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { BrandMark } from '@/shared/components/brand-mark'
|
||||
import { LanguageSwitcher } from '@/shared/components/language-switcher'
|
||||
import { ThemeToggle } from '@/shared/components/theme-toggle'
|
||||
import { UserMenu } from '@/shared/components/user-menu'
|
||||
import { NotificationBell } from '@/features/notification/notification-bell'
|
||||
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
|
||||
import { syncDocumentLanguage } from '@/shared/lib/document-language'
|
||||
import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { getAppHeaderClassName } from './layout-header-style'
|
||||
import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content'
|
||||
|
||||
const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none'
|
||||
|
||||
/**
|
||||
* Application shell shared by all routed pages.
|
||||
*
|
||||
|
|
@ -27,9 +33,22 @@ export function Layout() {
|
|||
})
|
||||
const { user, isLoading } = useAuth()
|
||||
const [isHeaderElevated, setIsHeaderElevated] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const previousPathnameRef = useRef(pathname)
|
||||
const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname)
|
||||
const mainContentLayout = getAppMainContentLayout(contentLayoutPathname)
|
||||
const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard')
|
||||
const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/')
|
||||
const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
|
||||
const filteredDashboardGroups = SIDEBAR_GROUPS
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => (
|
||||
(!item.admin || governanceVisible)
|
||||
&& (!item.passwordCapability || user?.canChangePassword === true)
|
||||
)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language)
|
||||
|
|
@ -72,6 +91,10 @@ export function Layout() {
|
|||
|
||||
const isActive = (to: string, exact?: boolean) => {
|
||||
if (exact) return pathname === to
|
||||
// 「控制台」按钮:仅在 /dashboard 主页或 /settings/* 时高亮
|
||||
if (to === '/dashboard') {
|
||||
return pathname === '/dashboard' || pathname.startsWith('/settings/')
|
||||
}
|
||||
// Keep matching strict so parent dashboard paths do not highlight unrelated child links.
|
||||
return pathname === to
|
||||
}
|
||||
|
|
@ -91,11 +114,12 @@ export function Layout() {
|
|||
|
||||
{/* Header */}
|
||||
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight text-brand-gradient">
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight flex-shrink-0" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
SkillHub
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{/* Desktop nav — lg+ only */}
|
||||
<nav className="hidden lg:flex items-center gap-5 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{navItems.map((item) => {
|
||||
if (item.auth && !user) return null
|
||||
const active = isActive(item.to, item.exact)
|
||||
|
|
@ -106,9 +130,10 @@ export function Layout() {
|
|||
to={item.to}
|
||||
className={
|
||||
active
|
||||
? 'px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm'
|
||||
: 'hover:opacity-80 transition-opacity duration-150'
|
||||
? 'px-4 py-1.5 rounded-full text-sm font-medium bg-foreground text-background shadow-[0_1px_2px_0_rgb(0_0_0/0.12)]'
|
||||
: 'px-4 py-1.5 rounded-full text-sm font-medium hover:opacity-90 transition-opacity duration-150'
|
||||
}
|
||||
style={active ? undefined : { color: 'hsl(var(--foreground) / 0.65)' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
|
|
@ -116,7 +141,17 @@ export function Layout() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 text-[15px] font-normal sm:gap-3" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{/* Hamburger — visible below lg */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden inline-flex items-center justify-center rounded-lg p-2 hover:bg-accent transition-colors"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label={t(mobileMenuOpen ? 'layout.closeNavigation' : 'layout.openNavigation')}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{user && <NotificationBell />}
|
||||
|
|
@ -133,104 +168,108 @@ export function Layout() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile nav dropdown */}
|
||||
{mobileMenuOpen ? (
|
||||
<div className="lg:hidden sticky top-[52px] z-40 border-b border-border bg-background/95 backdrop-blur-xl">
|
||||
<nav className="flex flex-col px-4 py-3 gap-1">
|
||||
{navItems.map((item) => {
|
||||
if (item.auth && !user) return null
|
||||
const active = isActive(item.to, item.exact)
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Main content */}
|
||||
<main className={mainContentLayout.mainClassName}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
<div className="h-10 w-48 animate-shimmer rounded-lg" />
|
||||
<div className="h-5 w-72 animate-shimmer rounded-md" />
|
||||
<div className="h-64 animate-shimmer rounded-xl" />
|
||||
<div className="space-y-3 animate-fade-up">
|
||||
<div className="h-8 w-36 animate-shimmer rounded-md" />
|
||||
<div className="h-4 w-56 animate-shimmer rounded-md" />
|
||||
<div className="h-48 animate-shimmer rounded-lg" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={mainContentLayout.contentClassName}>
|
||||
{showSidebar ? (
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<DashboardSidebar groups={filteredDashboardGroups} user={user} t={t} pathname={pathname} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-sm bg-brand-gradient">
|
||||
S
|
||||
</div>
|
||||
<span className="text-lg font-bold text-brand-gradient">SkillHub</span>
|
||||
<footer className="relative z-10 mt-auto border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-12 md:px-12 md:py-16">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-5">
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4 flex items-center gap-2.5">
|
||||
<BrandMark className="h-8 w-8 rounded-lg bg-background ring-1 ring-border/70" />
|
||||
<span className="font-semibold text-foreground">SkillHub</span>
|
||||
</div>
|
||||
<p className="text-sm max-w-xs" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('layout.footerDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('layout.footerDescription')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-12 md:gap-16">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('nav.home')}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'hsl(var(--text-secondary))' }}
|
||||
>
|
||||
{t('nav.search')}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/dashboard" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('footer.resources')}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('footer.docs')}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('footer.api')}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:opacity-80 transition-opacity" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('footer.community')}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.product')}</h4>
|
||||
<ul className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li><Link to="/search" search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }} className={FOOTER_LINK_CLASS_NAME}>{t('footer.marketplace')}</Link></li>
|
||||
<li><Link to="/dashboard/publish" className={FOOTER_LINK_CLASS_NAME}>{t('footer.publish')}</Link></li>
|
||||
<li><Link to="/dashboard" className={FOOTER_LINK_CLASS_NAME}>{t('nav.dashboard')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.developers')}</h4>
|
||||
<ul className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li><a href="https://github.com/iflytek/skillhub/tree/main/docs/skillhub" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.docs')}</a></li>
|
||||
<li><a href="https://www.npmjs.com/package/@astron-team/skillhub" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>CLI</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>GitHub</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.project')}</h4>
|
||||
<ul className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/LICENSE" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>License</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/CONTRIBUTING.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Contributing</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/releases" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Changelog</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/CODE_OF_CONDUCT.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.codeOfConduct')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.resources')}</h4>
|
||||
<ul className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li><a href="https://github.com/iflytek/skillhub/discussions" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.community')}</a></li>
|
||||
<li><Link to="/privacy" className={FOOTER_LINK_CLASS_NAME}>{t('footer.privacy')}</Link></li>
|
||||
<li><Link to="/terms" className={FOOTER_LINK_CLASS_NAME}>{t('footer.terms')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-10 pt-6 border-t flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 text-xs"
|
||||
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--muted-foreground))' }}
|
||||
>
|
||||
|
||||
<div className="mt-10 border-t border-border/70 pt-7 text-xs text-muted-foreground">
|
||||
<span>{t('footer.copyright')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/privacy" className="hover:opacity-80 transition-opacity">
|
||||
{t('footer.privacy')}
|
||||
</Link>
|
||||
<span>|</span>
|
||||
<Link to="/terms" className="hover:opacity-80 transition-opacity">
|
||||
{t('footer.terms')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -20,12 +20,29 @@ const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.sear
|
|||
// Export for use in cli-auth page
|
||||
export { ORIGINAL_URL_SEARCH }
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SilentRouteFallback() {
|
||||
return <div className="min-h-[40vh]" aria-hidden />
|
||||
}
|
||||
|
||||
interface LazyRouteOptions {
|
||||
silentFallback?: boolean
|
||||
}
|
||||
|
||||
function createLazyRouteComponent<TModule extends Record<string, unknown>>(
|
||||
importer: () => Promise<TModule>,
|
||||
exportName: keyof TModule,
|
||||
options: LazyRouteOptions = {},
|
||||
) {
|
||||
// Lazy route modules are wrapped in a uniform suspense fallback so route transitions behave
|
||||
// consistently across public and dashboard pages.
|
||||
// Most pages keep the visible route fallback. Dashboard-like tab pages can opt into a silent
|
||||
// route fallback so their own local data skeleton remains the only loading state users see.
|
||||
const LazyComponent = lazy(async () => {
|
||||
const module = await importer().catch((error) => {
|
||||
if (recoverFromDynamicImportError(error)) {
|
||||
|
|
@ -41,13 +58,7 @@ function createLazyRouteComponent<TModule extends Record<string, unknown>>(
|
|||
|
||||
return function LazyRouteComponent(props: Record<string, unknown>) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={options.silentFallback ? <SilentRouteFallback /> : <RouteLoadingFallback />}>
|
||||
<LazyComponent {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
|
|
@ -58,9 +69,10 @@ function createRoleProtectedRouteComponent<TModule extends Record<string, unknow
|
|||
importer: () => Promise<TModule>,
|
||||
exportName: keyof TModule,
|
||||
allowedRoles: readonly string[],
|
||||
options: LazyRouteOptions = {},
|
||||
) {
|
||||
// Role checks stay at the route edge so page modules can assume the minimum permission level.
|
||||
const RouteComponent = createLazyRouteComponent(importer, exportName)
|
||||
const RouteComponent = createLazyRouteComponent(importer, exportName, options)
|
||||
|
||||
return function RoleProtectedRouteComponent(props: Record<string, unknown>) {
|
||||
return (
|
||||
|
|
@ -82,58 +94,70 @@ const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'
|
|||
const NamespacePage = createLazyRouteComponent(() => import('@/pages/namespace'), 'NamespacePage')
|
||||
const SkillDetailPage = createLazyRouteComponent(() => import('@/pages/skill-detail'), 'SkillDetailPage')
|
||||
const SkillVersionComparePage = createLazyRouteComponent(() => import('@/pages/skill-version-compare'), 'SkillVersionComparePage')
|
||||
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage')
|
||||
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage')
|
||||
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage')
|
||||
const dashboardRouteOptions = { silentFallback: true } satisfies LazyRouteOptions
|
||||
|
||||
const DashboardPage = createLazyRouteComponent(() => import('@/pages/dashboard'), 'DashboardPage', dashboardRouteOptions)
|
||||
const MySkillsPage = createLazyRouteComponent(() => import('@/pages/dashboard/my-skills'), 'MySkillsPage', dashboardRouteOptions)
|
||||
const PublishPage = createLazyRouteComponent(() => import('@/pages/dashboard/publish'), 'PublishPage', dashboardRouteOptions)
|
||||
const MyNamespacesPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/my-namespaces'),
|
||||
'MyNamespacesPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const NamespaceMembersPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/namespace-members'),
|
||||
'NamespaceMembersPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const NamespaceReviewsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/namespace-reviews'),
|
||||
'NamespaceReviewsPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const NamespaceReviewDetailPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/review-detail'),
|
||||
'NamespaceReviewDetailPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage')
|
||||
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage')
|
||||
const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage', dashboardRouteOptions)
|
||||
const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage', dashboardRouteOptions)
|
||||
const ReviewProgressPage = createLazyRouteComponent(
|
||||
() => import('@/pages/dashboard/review-progress'),
|
||||
'ReviewProgressPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const ReportsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/reports'),
|
||||
'ReportsPage',
|
||||
['SKILL_ADMIN', 'SUPER_ADMIN'],
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage')
|
||||
const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage', dashboardRouteOptions)
|
||||
const PromotionsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/dashboard/promotions'),
|
||||
'PromotionsPage',
|
||||
['SKILL_ADMIN', 'SUPER_ADMIN'],
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage')
|
||||
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage')
|
||||
const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage', dashboardRouteOptions)
|
||||
const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions)
|
||||
const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage')
|
||||
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage')
|
||||
const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions)
|
||||
const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage')
|
||||
const SecuritySettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/security'),
|
||||
'SecuritySettingsPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const ProfileSettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/profile'),
|
||||
'ProfileSettingsPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const NotificationSettingsPage = createLazyRouteComponent(
|
||||
() => import('@/pages/settings/notification-settings'),
|
||||
'NotificationSettingsPage',
|
||||
dashboardRouteOptions,
|
||||
)
|
||||
const AdminUsersPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/admin/users'),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function buildUpdatedPreferences(
|
|||
/**
|
||||
* Renders the notification preference toggles for all supported categories.
|
||||
*/
|
||||
export function NotificationPreferenceForm() {
|
||||
export function NotificationPreferenceForm({ showHeader = true }: { showHeader?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { data: preferences = [], isLoading } = useNotificationPreferences()
|
||||
const { mutate: updatePreferences, isPending } = useUpdateNotificationPreferences()
|
||||
|
|
@ -49,10 +49,12 @@ export function NotificationPreferenceForm() {
|
|||
|
||||
return (
|
||||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('notification.preferences.title')}</CardTitle>
|
||||
<CardDescription>{t('notification.preferences.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
{showHeader ? (
|
||||
<CardHeader>
|
||||
<CardTitle>{t('notification.preferences.title')}</CardTitle>
|
||||
<CardDescription>{t('notification.preferences.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
) : null}
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border">
|
||||
{CATEGORIES.map((category) => {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,11 @@ import type { ReportDisposition } from '@/api/types'
|
|||
/**
|
||||
* Loads reported skills for the requested moderation status.
|
||||
*/
|
||||
export function useSkillReports(status: string) {
|
||||
export function useSkillReports(status: string, page = 0, size = 10) {
|
||||
return useQuery({
|
||||
queryKey: ['skill-reports', status],
|
||||
queryFn: async () => {
|
||||
const page = await reportApi.listSkillReports({ status })
|
||||
return page.items
|
||||
},
|
||||
queryKey: ['skill-reports', status, page, size],
|
||||
queryFn: () => reportApi.listSkillReports({ status, page, size }),
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
{version.status}
|
||||
</span>
|
||||
{isActiveReviewVersion(version, detail) ? (
|
||||
<span className="inline-flex items-center rounded-full bg-brand-gradient px-2.5 py-0.5 text-xs font-medium text-white">
|
||||
<span className="inline-flex items-center rounded-full bg-[#202020] px-2.5 py-0.5 text-xs font-medium text-white">
|
||||
{t('review.activeReviewVersion')}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
|
|||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 rounded-lg border border-border/60 bg-card p-1.5 shadow-[var(--shadow-card)]">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
|
|
@ -61,7 +61,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
|
|||
onChange={(e) => handleChange(e.target.value)}
|
||||
maxLength={MAX_SEARCH_INPUT_LENGTH}
|
||||
placeholder={placeholder || t('searchBar.placeholder')}
|
||||
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
|
||||
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-10"
|
||||
/>
|
||||
{currentQuery ? (
|
||||
<button
|
||||
|
|
@ -75,7 +75,7 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching =
|
|||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" size="lg" className="px-8 min-w-28" disabled={isSearching}>
|
||||
<Button type="submit" size="default" className="px-5 min-w-24 rounded-md" disabled={isSearching}>
|
||||
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : t('searchBar.button')}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ vi.mock('@/features/auth/use-auth', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/social/use-star', () => ({
|
||||
useStarredIdSet: () => ({
|
||||
starredIds: new Set<number>(),
|
||||
|
|
@ -40,6 +47,30 @@ describe('skill-card module exports', () => {
|
|||
expect(typeof mod.SkillCard).toBe('function')
|
||||
})
|
||||
|
||||
it('limits long summaries to the stable three-line description region', () => {
|
||||
const summary = 'A long skill summary that should remain available as a tooltip while the visible card content stays clamped.'
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SkillCard, {
|
||||
skill: {
|
||||
id: 1,
|
||||
slug: 'summary-writer',
|
||||
displayName: 'Summary Writer',
|
||||
summary,
|
||||
downloadCount: 0,
|
||||
starCount: 0,
|
||||
ratingCount: 0,
|
||||
namespace: 'global',
|
||||
updatedAt: '2026-09-07T00:00:00Z',
|
||||
canSubmitPromotion: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(html).toContain('skill-card-summary')
|
||||
expect(html).toContain(`title="${summary}"`)
|
||||
expect(html).toContain('[overflow-wrap:anywhere]')
|
||||
})
|
||||
|
||||
it('renders compliance badges from the skill summary snapshot', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SkillCard, {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type { SkillSummary } from '@/api/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { useStarredIdSet } from '@/features/social/use-star'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
|
||||
import { formatCompactCount } from '@/shared/lib/number-format'
|
||||
import { formatRelativeTime } from '@/shared/lib/format-relative-time'
|
||||
import { Bookmark, ShieldCheck, User, Clock } from 'lucide-react'
|
||||
|
||||
interface SkillCardProps {
|
||||
|
|
@ -13,29 +15,11 @@ interface SkillCardProps {
|
|||
highlightStarred?: boolean
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
const diffYears = Math.floor(diffDays / 365)
|
||||
|
||||
if (diffSeconds < 60) return '刚刚'
|
||||
if (diffMinutes < 60) return `${diffMinutes}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 30) return `${diffDays}天前`
|
||||
if (diffMonths < 12) return `${diffMonths}个月前`
|
||||
return `${diffYears}年前`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable card for displaying one skill in lists such as landing, namespace, search, and stars.
|
||||
*/
|
||||
export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { isAuthenticated } = useAuth()
|
||||
// Batch highlight via shared ['skills','stars'] — never N× useStar per grid row.
|
||||
const { starredIds } = useStarredIdSet(highlightStarred && isAuthenticated)
|
||||
|
|
@ -43,11 +27,14 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
const headlineVersion = getHeadlineVersion(skill)
|
||||
const isInteractive = typeof onClick === 'function'
|
||||
const complianceItems = skill.complianceSnapshot?.items?.filter((item) => item.standard || item.controlId) ?? []
|
||||
const downloadLabel = t('skillCard.downloads', { value: formatCompactCount(skill.downloadCount) })
|
||||
const starLabel = t('skillCard.stars', { count: skill.starCount })
|
||||
const ratingLabel = t('skillCard.rating', { rating: skill.ratingAvg?.toFixed(1) ?? '0.0' })
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
className="group relative h-full cursor-pointer overflow-hidden rounded-md border border-border/60 bg-card p-4 text-card-foreground transition-[transform,box-shadow,border-color] duration-150 ease-out card-hover focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15"
|
||||
style={{ borderColor: 'hsl(var(--border-card))', boxShadow: 'var(--shadow-card)' }}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (!isInteractive) {
|
||||
|
|
@ -65,7 +52,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
<h3 className="font-semibold text-base group-hover:text-primary transition-colors duration-150" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -75,7 +62,10 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
</div>
|
||||
|
||||
{skill.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
|
||||
<p
|
||||
className="skill-card-summary mb-3 text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]"
|
||||
title={skill.summary}
|
||||
>
|
||||
{skill.summary}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -102,25 +92,27 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
|
||||
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{headlineVersion && (
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
|
||||
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono text-[11px] font-semibold" style={{ color: 'hsl(215 30% 35%)' }}>
|
||||
v{headlineVersion.version}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 text-muted-foreground" title={downloadLabel} aria-label={downloadLabel}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
|
||||
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}
|
||||
title={starLabel}
|
||||
aria-label={starLabel}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
|
||||
{skill.starCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400" title={ratingLabel} aria-label={ratingLabel}>
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{skill.ratingAvg.toFixed(1)}
|
||||
|
|
@ -138,7 +130,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
{skill.updatedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(skill.updatedAt)}
|
||||
{formatRelativeTime(skill.updatedAt, i18n.language)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,9 +144,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
|
|||
<DialogContent>
|
||||
{!createdToken ? (
|
||||
<>
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('createToken.title')}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
<DialogHeader className="text-center">
|
||||
<DialogTitle>{t('createToken.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('createToken.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -239,9 +239,9 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader className="min-w-0 text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('createToken.successTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-center break-words">
|
||||
<DialogHeader className="min-w-0 text-center">
|
||||
<DialogTitle>{t('createToken.successTitle')}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{t('createToken.successDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,119 @@
|
|||
"code": "# Publish skill\nclawhub publish\n\n# Or use web interface\n# Click \"Publish Skill\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"heroBadge": "Agent skill management platform",
|
||||
"heroTitle": "Turn team expertise into Agent-ready skills",
|
||||
"heroDescription": "Make individual AI know-how reusable, manageable, and trusted across the organization.",
|
||||
"browseAll": "Browse all",
|
||||
"marketplace": "Marketplace",
|
||||
"latest": "Latest",
|
||||
"demoSkills": {
|
||||
"weather": "Query real-time weather for cities worldwide",
|
||||
"gitHelper": "Assisted Git workflows",
|
||||
"diagramMaker": "Generate flowcharts and architecture diagrams",
|
||||
"codeReviewer": "Automated code review",
|
||||
"caption": "Interface preview · 4 sample skills"
|
||||
},
|
||||
"highlights": {
|
||||
"selfHosted": "Self-hosted registry",
|
||||
"governance": "Review and governance",
|
||||
"multiClient": "Agent, CLI, and Web access",
|
||||
"traceable": "Traceable versions"
|
||||
},
|
||||
"capabilities": {
|
||||
"eyebrow": "Capabilities",
|
||||
"title": "Manage the complete skill lifecycle, from registration to distribution",
|
||||
"description": "More than a package repository: one platform for publishing, review, distribution, and governance."
|
||||
},
|
||||
"enterprise": {
|
||||
"title": "Built for teams and enterprises",
|
||||
"description": "Self-hosting, access control, review workflows, and auditability in one registry.",
|
||||
"clientLayer": "Clients",
|
||||
"serviceLayer": "Service",
|
||||
"storageLayer": "Storage",
|
||||
"networkBoundary": "Deploy inside your organization and retain control of registry data",
|
||||
"deployment": {
|
||||
"title": "Self-hosted deployment",
|
||||
"description": "Run SkillHub on your infrastructure with Docker Compose or the provided Kubernetes manifests, using local or S3-compatible object storage."
|
||||
},
|
||||
"security": {
|
||||
"title": "Validation and review",
|
||||
"description": "Package validation, security scanning, and review workflows help teams assess skills before publication."
|
||||
},
|
||||
"rbac": {
|
||||
"title": "Layered RBAC",
|
||||
"description": "Platform and namespace roles separate organization-wide administration from team ownership."
|
||||
},
|
||||
"access": {
|
||||
"title": "Flexible access",
|
||||
"description": "OAuth2 and local accounts support browser sign-in, while API tokens enable programmatic access."
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit trail",
|
||||
"description": "Audit records make key publishing, download, deletion, and authorization operations traceable."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Open source",
|
||||
"description": "The code is available under Apache-2.0 for inspection, self-hosting, and community contribution."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Build your team skill registry",
|
||||
"description": "Review the source and deployment guide, then run SkillHub in your own environment.",
|
||||
"deploy": "View source",
|
||||
"docs": "Read the docs"
|
||||
},
|
||||
"quickStart": {
|
||||
"title": "Choose an access method",
|
||||
"description": "Use the same registry through an Agent, the CLI, or the Web interface.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyErrorTitle": "Copy failed",
|
||||
"modes": {
|
||||
"agent": { "title": "Agent integration", "description": "Configure the Registry once and discover skills as needed" },
|
||||
"cli": { "title": "CLI", "description": "Search, install, and publish skills" },
|
||||
"web": { "title": "Web interface", "description": "Browse, read, and download skills visually" }
|
||||
},
|
||||
"agent": {
|
||||
"tablist": "Agent integration demo",
|
||||
"registryTab": "Registry setup",
|
||||
"discoveryTab": "Implicit discovery",
|
||||
"live": "Agent live",
|
||||
"instruction": "Read {{url}} and follow it to configure the SkillHub Skills Registry",
|
||||
"instructionLead": "Copy this instruction and send it to your Agent",
|
||||
"instructionPrefix": "Read",
|
||||
"instructionSuffix": "and follow it to configure the SkillHub Skills Registry.",
|
||||
"copyInstruction": "Copy instruction",
|
||||
"copyErrorDescription": "Select and copy the Registry setup instruction manually.",
|
||||
"steps": { "read": "Read the guide", "configure": "Configure Registry", "discover": "Discover skills on demand" },
|
||||
"prompt": "What is the weather in Hefei today?",
|
||||
"discovery": {
|
||||
"intentTitle": "Identify the task", "intentDescription": "Real-time weather and travel advice are needed",
|
||||
"searchTitle": "Search SkillHub Registry", "searchDescription": "Match @global/weather · v1.3.0",
|
||||
"readTitle": "Read skill instructions", "readDescription": "Confirm the input and invocation contract"
|
||||
},
|
||||
"answerLead": "Hefei is cloudy today, 26°C",
|
||||
"answerTail": ", with possible brief showers this afternoon. Carrying rain gear is recommended."
|
||||
},
|
||||
"cli": {
|
||||
"copyErrorDescription": "Select and copy the CLI command manually.",
|
||||
"skillsFound": "Sample results:",
|
||||
"forecastSummary": "Seven-day weather forecast",
|
||||
"installed": "@global/weather installed to ./skills/global/weather",
|
||||
"connected": "connected",
|
||||
"docs": "CLI docs"
|
||||
},
|
||||
"web": {
|
||||
"marketplace": "Skill marketplace", "publicSkills": "Public skills", "search": "Search skills",
|
||||
"status": "Public · Available", "summary": "Real-time weather, forecasts, and travel information",
|
||||
"download": "Download ZIP", "readTitle": "Read before using",
|
||||
"readDescription": "Review the purpose, inputs, and execution instructions in SKILL.md before downloading the complete package.",
|
||||
"version": "Version", "files": "Files", "format": "Format",
|
||||
"browse": "Browse and search", "preview": "Content preview", "versions": "Files and versions", "favorites": "Stars and subscriptions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
@ -323,7 +436,8 @@
|
|||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "View your account, skills, and access credentials in one place",
|
||||
"subtitle": "Manage your skills, account, and preferences",
|
||||
"overview": "Overview",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"userInfo": "Account Information",
|
||||
"userInfoDesc": "Basic account details and platform roles",
|
||||
|
|
@ -348,6 +462,37 @@
|
|||
"previewMoreLabel": "View All",
|
||||
"userId": "User ID"
|
||||
},
|
||||
"sidebar": {
|
||||
"account": "Account",
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
"notifications": "Notifications",
|
||||
"skillsAndData": "Skills & Data",
|
||||
"mySkills": "My Skills",
|
||||
"publish": "Publish",
|
||||
"namespaces": "Namespaces",
|
||||
"stars": "My Stars",
|
||||
"subscriptions": "Subscriptions",
|
||||
"tokens": "API Tokens",
|
||||
"reviewProgress": "Review Progress",
|
||||
"admin": "Admin",
|
||||
"governance": "Governance",
|
||||
"reports": "Reports"
|
||||
},
|
||||
"overview": {
|
||||
"mySkills": "My Skills",
|
||||
"mySkillsDesc": "View and manage all your published skills",
|
||||
"publish": "Publish",
|
||||
"publishDesc": "Upload and publish a new skill",
|
||||
"tokens": "API Tokens",
|
||||
"tokensDesc": "Manage your API access tokens",
|
||||
"stars": "My Stars",
|
||||
"starsDesc": "View your starred and rated skills",
|
||||
"profile": "Profile",
|
||||
"profileDesc": "Edit your display name and personal info",
|
||||
"security": "Security",
|
||||
"securityDesc": "Change password and account security options"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "My Skills",
|
||||
"subtitle": "Manage your published skills",
|
||||
|
|
@ -1261,6 +1406,8 @@
|
|||
"folderHint": "Or select a folder to package and upload"
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "Open navigation menu",
|
||||
"closeNavigation": "Close navigation menu",
|
||||
"footerDescription": "Skill registry, providing efficient skill management and distribution for developers."
|
||||
},
|
||||
"nsReviews": {
|
||||
|
|
@ -1382,6 +1529,9 @@
|
|||
"subscribe": "Subscribe"
|
||||
},
|
||||
"skillCard": {
|
||||
"downloads": "{{value}} downloads",
|
||||
"stars": "{{count}} stars",
|
||||
"rating": "Rating {{rating}}",
|
||||
"starred": "Starred",
|
||||
"starredAction": "Click to unstar",
|
||||
"unstarTitle": "Remove from starred",
|
||||
|
|
@ -1393,6 +1543,7 @@
|
|||
"copy": "Copy"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"pagePrefix": "Page",
|
||||
|
|
@ -1423,13 +1574,19 @@
|
|||
}
|
||||
},
|
||||
"footer": {
|
||||
"product": "Product",
|
||||
"marketplace": "Skill marketplace",
|
||||
"publish": "Publish skill",
|
||||
"developers": "Developers",
|
||||
"project": "Project",
|
||||
"codeOfConduct": "Code of conduct",
|
||||
"resources": "Resources",
|
||||
"docs": "Documentation",
|
||||
"api": "API",
|
||||
"community": "Community",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service",
|
||||
"copyright": "© 2026 SkillHub. All rights reserved."
|
||||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"title": "Publish Skill",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,119 @@
|
|||
"code": "# Опубликовать скилл\nclawhub publish\n\n# Или через веб-интерфейс\n# Нажмите «Опубликовать скилл»"
|
||||
}
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"heroBadge": "Платформа управления скиллами агентов",
|
||||
"heroTitle": "Превратите экспертизу команды в скиллы для агентов",
|
||||
"heroDescription": "Сделайте индивидуальный опыт работы с AI повторно используемым, управляемым и надёжным активом организации.",
|
||||
"browseAll": "Смотреть все",
|
||||
"marketplace": "Каталог",
|
||||
"latest": "Новинки",
|
||||
"demoSkills": {
|
||||
"weather": "Погода в городах мира в реальном времени",
|
||||
"gitHelper": "Помощник для операций Git",
|
||||
"diagramMaker": "Создание блок-схем и архитектурных диаграмм",
|
||||
"codeReviewer": "Автоматическое ревью кода",
|
||||
"caption": "Предпросмотр интерфейса · 4 примера"
|
||||
},
|
||||
"highlights": {
|
||||
"selfHosted": "Self-hosted реестр",
|
||||
"governance": "Ревью и управление",
|
||||
"multiClient": "Доступ через Agent, CLI и Web",
|
||||
"traceable": "Прослеживаемые версии"
|
||||
},
|
||||
"capabilities": {
|
||||
"eyebrow": "Возможности",
|
||||
"title": "Полный жизненный цикл скилла — от регистрации до распространения",
|
||||
"description": "Единая платформа для публикации, ревью, распространения и управления, а не просто хранилище пакетов."
|
||||
},
|
||||
"enterprise": {
|
||||
"title": "Для команд и предприятий",
|
||||
"description": "Self-hosting, контроль доступа, ревью и аудит в одном реестре.",
|
||||
"clientLayer": "Клиенты",
|
||||
"serviceLayer": "Сервис",
|
||||
"storageLayer": "Хранилище",
|
||||
"networkBoundary": "Развёртывайте внутри организации и контролируйте данные реестра",
|
||||
"deployment": {
|
||||
"title": "Self-hosted развёртывание",
|
||||
"description": "Запускайте SkillHub в своей инфраструктуре через Docker Compose или предоставленные Kubernetes-манифесты с локальным либо S3-совместимым хранилищем."
|
||||
},
|
||||
"security": {
|
||||
"title": "Проверка и ревью",
|
||||
"description": "Проверка пакетов, сканирование безопасности и процессы ревью помогают оценивать скиллы до публикации."
|
||||
},
|
||||
"rbac": {
|
||||
"title": "Многоуровневый RBAC",
|
||||
"description": "Роли платформы и пространства имён разделяют глобальное администрирование и владение команды."
|
||||
},
|
||||
"access": {
|
||||
"title": "Гибкий доступ",
|
||||
"description": "OAuth2 и локальные аккаунты поддерживают вход в браузере, а API-токены — программный доступ."
|
||||
},
|
||||
"audit": {
|
||||
"title": "Аудит",
|
||||
"description": "Ключевые операции публикации, загрузки, удаления и авторизации сохраняются для последующего анализа."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Открытый код",
|
||||
"description": "Код доступен по лицензии Apache-2.0 для проверки, самостоятельного размещения и участия сообщества."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Создайте реестр скиллов своей команды",
|
||||
"description": "Изучите исходный код и руководство по развёртыванию, затем запустите SkillHub в своей среде.",
|
||||
"deploy": "Исходный код",
|
||||
"docs": "Документация"
|
||||
},
|
||||
"quickStart": {
|
||||
"title": "Выберите способ доступа",
|
||||
"description": "Используйте один реестр через Agent, CLI или веб-интерфейс.",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyErrorTitle": "Не удалось скопировать",
|
||||
"modes": {
|
||||
"agent": { "title": "Интеграция Agent", "description": "Настройте Registry один раз и находите скиллы по мере необходимости" },
|
||||
"cli": { "title": "CLI", "description": "Ищите, устанавливайте и публикуйте скиллы" },
|
||||
"web": { "title": "Веб-интерфейс", "description": "Просматривайте, читайте и загружайте скиллы" }
|
||||
},
|
||||
"agent": {
|
||||
"tablist": "Демонстрация интеграции Agent",
|
||||
"registryTab": "Настройка Registry",
|
||||
"discoveryTab": "Неявный поиск",
|
||||
"live": "Agent онлайн",
|
||||
"instruction": "Прочитайте {{url}} и следуйте инструкции для настройки SkillHub Skills Registry",
|
||||
"instructionLead": "Скопируйте инструкцию и отправьте её своему Agent",
|
||||
"instructionPrefix": "Прочитайте",
|
||||
"instructionSuffix": "и следуйте ей для настройки SkillHub Skills Registry.",
|
||||
"copyInstruction": "Копировать инструкцию",
|
||||
"copyErrorDescription": "Выделите и скопируйте инструкцию настройки Registry вручную.",
|
||||
"steps": { "read": "Прочитать руководство", "configure": "Настроить Registry", "discover": "Находить скиллы по запросу" },
|
||||
"prompt": "Какая сегодня погода в Хэфэе?",
|
||||
"discovery": {
|
||||
"intentTitle": "Определить задачу", "intentDescription": "Нужны погода и совет для поездки",
|
||||
"searchTitle": "Поиск в SkillHub Registry", "searchDescription": "Найден @global/weather · v1.3.0",
|
||||
"readTitle": "Прочитать инструкции", "readDescription": "Проверить входные данные и способ вызова"
|
||||
},
|
||||
"answerLead": "Сегодня в Хэфэе облачно, 26°C",
|
||||
"answerTail": ", днём возможен кратковременный дождь. Рекомендуется взять зонт."
|
||||
},
|
||||
"cli": {
|
||||
"copyErrorDescription": "Выделите и скопируйте команду CLI вручную.",
|
||||
"skillsFound": "Пример результатов:",
|
||||
"forecastSummary": "Прогноз погоды на семь дней",
|
||||
"installed": "@global/weather установлен в ./skills/global/weather",
|
||||
"connected": "подключено",
|
||||
"docs": "Документация CLI"
|
||||
},
|
||||
"web": {
|
||||
"marketplace": "Каталог скиллов", "publicSkills": "Публичные скиллы", "search": "Поиск скиллов",
|
||||
"status": "Публичный · Доступен", "summary": "Погода, прогнозы и рекомендации для поездок",
|
||||
"download": "Скачать ZIP", "readTitle": "Сначала прочитайте",
|
||||
"readDescription": "Изучите назначение, входные данные и способ выполнения в SKILL.md перед загрузкой полного пакета.",
|
||||
"version": "Версия", "files": "Файлы", "format": "Формат",
|
||||
"browse": "Просмотр и поиск", "preview": "Предпросмотр", "versions": "Файлы и версии", "favorites": "Избранное и подписки"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
@ -323,7 +436,8 @@
|
|||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
"subtitle": "Аккаунт, скиллы и учётные данные доступа — в одном месте",
|
||||
"subtitle": "Управляйте скиллами, аккаунтом и настройками",
|
||||
"overview": "Обзор",
|
||||
"backToDashboard": "Назад к панели",
|
||||
"userInfo": "Сведения об аккаунте",
|
||||
"userInfoDesc": "Основные данные аккаунта и роли на платформе",
|
||||
|
|
@ -348,6 +462,37 @@
|
|||
"previewMoreLabel": "Смотреть все",
|
||||
"userId": "ID пользователя"
|
||||
},
|
||||
"sidebar": {
|
||||
"account": "Аккаунт",
|
||||
"profile": "Профиль",
|
||||
"security": "Безопасность",
|
||||
"notifications": "Уведомления",
|
||||
"skillsAndData": "Скиллы и данные",
|
||||
"mySkills": "Мои скиллы",
|
||||
"publish": "Публикация",
|
||||
"namespaces": "Пространства",
|
||||
"stars": "Мои звёзды",
|
||||
"subscriptions": "Подписки",
|
||||
"tokens": "Токены API",
|
||||
"reviewProgress": "Статус проверки",
|
||||
"admin": "Админ",
|
||||
"governance": "Управление",
|
||||
"reports": "Жалобы"
|
||||
},
|
||||
"overview": {
|
||||
"mySkills": "Мои скиллы",
|
||||
"mySkillsDesc": "Просмотр и управление опубликованными скиллами",
|
||||
"publish": "Публикация",
|
||||
"publishDesc": "Загрузить и опубликовать новый скилл",
|
||||
"tokens": "Токены API",
|
||||
"tokensDesc": "Управление токенами доступа к API",
|
||||
"stars": "Мои звёзды",
|
||||
"starsDesc": "Просмотр избранных и оценённых скиллов",
|
||||
"profile": "Профиль",
|
||||
"profileDesc": "Редактирование отображаемого имени и информации",
|
||||
"security": "Безопасность",
|
||||
"securityDesc": "Изменение пароля и параметров безопасности"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "Мои скиллы",
|
||||
"subtitle": "Управление опубликованными скиллами",
|
||||
|
|
@ -1399,15 +1544,23 @@
|
|||
"title": "Файлы"
|
||||
},
|
||||
"footer": {
|
||||
"product": "Продукт",
|
||||
"marketplace": "Каталог скиллов",
|
||||
"publish": "Опубликовать скилл",
|
||||
"developers": "Разработчикам",
|
||||
"project": "Проект",
|
||||
"codeOfConduct": "Кодекс поведения",
|
||||
"resources": "Ресурсы",
|
||||
"docs": "Документация",
|
||||
"api": "API",
|
||||
"community": "Сообщество",
|
||||
"privacy": "Политика конфиденциальности",
|
||||
"terms": "Условия использования",
|
||||
"copyright": "© 2026 SkillHub. Все права защищены."
|
||||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "Открыть меню навигации",
|
||||
"closeNavigation": "Закрыть меню навигации",
|
||||
"footerDescription": "Реестр скиллов: эффективное управление и распространение скиллов для разработчиков."
|
||||
},
|
||||
"loginButton": {
|
||||
|
|
@ -1469,6 +1622,7 @@
|
|||
"archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Навигация по страницам",
|
||||
"prev": "Назад",
|
||||
"next": "Вперёд",
|
||||
"pagePrefix": "Стр.",
|
||||
|
|
@ -1654,6 +1808,9 @@
|
|||
}
|
||||
},
|
||||
"skillCard": {
|
||||
"downloads": "Загрузки: {{value}}",
|
||||
"stars": "Звёзды: {{count}}",
|
||||
"rating": "Рейтинг {{rating}}",
|
||||
"starred": "В избранном",
|
||||
"starredAction": "Нажмите, чтобы убрать из избранного",
|
||||
"unstarTitle": "Убрать из избранного",
|
||||
|
|
|
|||
|
|
@ -146,6 +146,119 @@
|
|||
"code": "# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"heroBadge": "Agent 技能管理平台",
|
||||
"heroTitle": "把团队的专业能力,沉淀成 Agent 可用的技能",
|
||||
"heroDescription": "让 AI 能力从个人经验变成可复用、可管理、可信赖的组织资产。",
|
||||
"browseAll": "浏览全部",
|
||||
"marketplace": "技能市场",
|
||||
"latest": "最新发布",
|
||||
"demoSkills": {
|
||||
"weather": "查询全球城市实时天气",
|
||||
"gitHelper": "智能 Git 操作辅助",
|
||||
"diagramMaker": "生成流程图和架构图",
|
||||
"codeReviewer": "自动代码审查",
|
||||
"caption": "界面预览 · 4 个示例技能"
|
||||
},
|
||||
"highlights": {
|
||||
"selfHosted": "自托管 Registry",
|
||||
"governance": "审核与治理",
|
||||
"multiClient": "Agent、CLI、Web 接入",
|
||||
"traceable": "版本可追溯"
|
||||
},
|
||||
"capabilities": {
|
||||
"eyebrow": "核心能力",
|
||||
"title": "从注册到分发,管理完整的技能生命周期",
|
||||
"description": "不只是技能仓库,而是一套覆盖发布、审核、分发和治理的平台。"
|
||||
},
|
||||
"enterprise": {
|
||||
"title": "为团队和企业而建",
|
||||
"description": "在一个 Registry 中提供自托管、权限控制、审核流程与审计能力。",
|
||||
"clientLayer": "接入端",
|
||||
"serviceLayer": "服务层",
|
||||
"storageLayer": "存储层",
|
||||
"networkBoundary": "部署在企业环境中,自主管理 Registry 数据",
|
||||
"deployment": {
|
||||
"title": "自托管部署",
|
||||
"description": "使用 Docker Compose 或仓库提供的 Kubernetes 清单部署 SkillHub,并选择本地或 S3 兼容对象存储。"
|
||||
},
|
||||
"security": {
|
||||
"title": "校验与审核",
|
||||
"description": "通过技能包校验、安全扫描和审核流程,帮助团队在发布前评估技能。"
|
||||
},
|
||||
"rbac": {
|
||||
"title": "分层 RBAC",
|
||||
"description": "平台角色与命名空间角色分离全局管理和团队所有权。"
|
||||
},
|
||||
"access": {
|
||||
"title": "灵活接入",
|
||||
"description": "浏览器支持 OAuth2 和本地账号登录,API Token 用于程序化访问。"
|
||||
},
|
||||
"audit": {
|
||||
"title": "审计记录",
|
||||
"description": "关键的发布、下载、删除和授权操作都有审计记录,便于追溯。"
|
||||
},
|
||||
"openSource": {
|
||||
"title": "开源透明",
|
||||
"description": "代码采用 Apache-2.0 许可证,可用于检查、自托管和社区贡献。"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "构建团队自己的技能 Registry",
|
||||
"description": "查看源代码和部署文档,然后在自己的环境中运行 SkillHub。",
|
||||
"deploy": "查看源码",
|
||||
"docs": "查看文档"
|
||||
},
|
||||
"quickStart": {
|
||||
"title": "选择接入方式",
|
||||
"description": "通过 Agent、CLI 或 Web 界面使用同一个技能 Registry。",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyErrorTitle": "复制失败",
|
||||
"modes": {
|
||||
"agent": { "title": "Agent 自动接入", "description": "配置一次 Registry,按需发现技能" },
|
||||
"cli": { "title": "CLI 命令行", "description": "搜索、获取和发布技能" },
|
||||
"web": { "title": "Web 界面", "description": "可视化浏览、阅读并下载技能" }
|
||||
},
|
||||
"agent": {
|
||||
"tablist": "Agent 接入演示",
|
||||
"registryTab": "Registry 配置",
|
||||
"discoveryTab": "隐式发现",
|
||||
"live": "Agent 实时",
|
||||
"instruction": "阅读 {{url}},并按照说明完成 SkillHub Skills Registry 的配置",
|
||||
"instructionLead": "复制以下指令,发送给 Agent 即可完成配置",
|
||||
"instructionPrefix": "阅读",
|
||||
"instructionSuffix": ",并按照说明完成 SkillHub Skills Registry 的配置。",
|
||||
"copyInstruction": "复制指令",
|
||||
"copyErrorDescription": "请手动选择并复制这条 Registry 配置指令。",
|
||||
"steps": { "read": "读取文档", "configure": "配置 Registry", "discover": "按需发现技能" },
|
||||
"prompt": "帮我查下合肥今天的天气",
|
||||
"discovery": {
|
||||
"intentTitle": "识别任务意图", "intentDescription": "需要实时天气与出行建议",
|
||||
"searchTitle": "检索 SkillHub Registry", "searchDescription": "匹配 @global/weather · v1.3.0",
|
||||
"readTitle": "读取技能说明", "readDescription": "确认输入格式和调用方式"
|
||||
},
|
||||
"answerLead": "合肥今天多云,26°C",
|
||||
"answerTail": ",下午可能有短时阵雨,外出建议携带雨具。"
|
||||
},
|
||||
"cli": {
|
||||
"copyErrorDescription": "请手动选择并复制这条 CLI 命令。",
|
||||
"skillsFound": "示例结果:",
|
||||
"forecastSummary": "7 天天气预报",
|
||||
"installed": "@global/weather 已安装到 ./skills/global/weather",
|
||||
"connected": "已连接",
|
||||
"docs": "CLI 文档"
|
||||
},
|
||||
"web": {
|
||||
"marketplace": "技能市场", "publicSkills": "公开技能", "search": "搜索技能",
|
||||
"status": "公开 · 可用", "summary": "查询实时天气、未来预报和出行信息",
|
||||
"download": "下载 ZIP", "readTitle": "先阅读,再使用",
|
||||
"readDescription": "阅读 SKILL.md 中的用途、输入要求和执行方式,确认适合当前任务后再获取完整技能包。",
|
||||
"version": "版本", "files": "文件", "format": "格式",
|
||||
"browse": "浏览与搜索", "preview": "内容预览", "versions": "文件与版本", "favorites": "收藏和订阅"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
@ -322,8 +435,9 @@
|
|||
"goToLogin": "前往登录"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "统一查看账户信息、技能资产与访问凭证",
|
||||
"title": "控制台",
|
||||
"subtitle": "管理你的技能、账户与偏好设置",
|
||||
"overview": "概览",
|
||||
"backToDashboard": "返回控制台",
|
||||
"userInfo": "用户信息",
|
||||
"userInfoDesc": "查看当前账户的基础信息与平台角色",
|
||||
|
|
@ -335,7 +449,7 @@
|
|||
"viewSubscriptions": "查看我的订阅",
|
||||
"mySkillsTitle": "我的技能",
|
||||
"openMySkills": "查看我的技能",
|
||||
"mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往“我的技能”查看全部。",
|
||||
"mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往\u201C我的技能\u201D查看全部。",
|
||||
"mySkillsPreviewEmpty": "你还没有发布任何技能",
|
||||
"credentials": "访问凭证",
|
||||
"openTokens": "查看 API Tokens",
|
||||
|
|
@ -348,6 +462,37 @@
|
|||
"previewMoreLabel": "查看全部",
|
||||
"userId": "用户 ID"
|
||||
},
|
||||
"sidebar": {
|
||||
"account": "账号管理",
|
||||
"profile": "个人设置",
|
||||
"security": "安全设置",
|
||||
"notifications": "通知设置",
|
||||
"skillsAndData": "技能与数据",
|
||||
"mySkills": "我的技能",
|
||||
"publish": "技能发布",
|
||||
"namespaces": "命名空间",
|
||||
"stars": "我的收藏",
|
||||
"subscriptions": "我的订阅",
|
||||
"tokens": "Token 凭证",
|
||||
"reviewProgress": "我的审核进度",
|
||||
"admin": "管理员",
|
||||
"governance": "审核与治理",
|
||||
"reports": "举报管理"
|
||||
},
|
||||
"overview": {
|
||||
"mySkills": "我的技能",
|
||||
"mySkillsDesc": "查看和管理你发布的所有技能",
|
||||
"publish": "技能发布",
|
||||
"publishDesc": "上传并发布新的技能到平台",
|
||||
"tokens": "Token 凭证",
|
||||
"tokensDesc": "管理 API 访问令牌",
|
||||
"stars": "我的收藏",
|
||||
"starsDesc": "查看你收藏和评分的技能",
|
||||
"profile": "个人设置",
|
||||
"profileDesc": "编辑你的展示名称和个人信息",
|
||||
"security": "安全设置",
|
||||
"securityDesc": "修改密码和账户安全选项"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "我的技能",
|
||||
"subtitle": "管理你发布的技能",
|
||||
|
|
@ -1261,6 +1406,8 @@
|
|||
"folderHint": "或选择文件夹,自动打包上传"
|
||||
},
|
||||
"layout": {
|
||||
"openNavigation": "打开导航菜单",
|
||||
"closeNavigation": "关闭导航菜单",
|
||||
"footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。"
|
||||
},
|
||||
"nsReviews": {
|
||||
|
|
@ -1381,6 +1528,9 @@
|
|||
"subscribe": "订阅"
|
||||
},
|
||||
"skillCard": {
|
||||
"downloads": "下载 {{value}}",
|
||||
"stars": "收藏 {{count}}",
|
||||
"rating": "评分 {{rating}}",
|
||||
"starred": "已收藏",
|
||||
"starredAction": "点击取消收藏",
|
||||
"unstarTitle": "取消收藏",
|
||||
|
|
@ -1392,6 +1542,7 @@
|
|||
"copy": "复制"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "分页导航",
|
||||
"prev": "上一页",
|
||||
"next": "下一页",
|
||||
"pagePrefix": "第",
|
||||
|
|
@ -1422,13 +1573,19 @@
|
|||
}
|
||||
},
|
||||
"footer": {
|
||||
"product": "产品",
|
||||
"marketplace": "技能市场",
|
||||
"publish": "发布技能",
|
||||
"developers": "开发者",
|
||||
"project": "项目",
|
||||
"codeOfConduct": "行为准则",
|
||||
"resources": "资源",
|
||||
"docs": "文档",
|
||||
"api": "API",
|
||||
"community": "社区",
|
||||
"privacy": "隐私政策",
|
||||
"terms": "服务条款",
|
||||
"copyright": "© 2026 SkillHub. 保留所有权利。"
|
||||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"title": "发布技能",
|
||||
|
|
|
|||
|
|
@ -4,80 +4,124 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
/* SkillHub — 浅色主题 (indigo-violet brand) */
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 220 26% 14%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 26% 14%;
|
||||
/* ═══════════════════════════════════════════
|
||||
SkillHub — 白 + 近黑(ref: skillhub-clone, TabTin, skillhub.cn)
|
||||
纯白底,冷灰卡片 #f2f3f5,近黑按钮 #202020
|
||||
无品牌色,所有表达通过灰阶深度
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ── 基础层 — 纯白底,对标 skillhub-clone / skillhub.cn ── */
|
||||
--background: 0 0% 100%; /* #ffffff */
|
||||
--foreground: 0 0% 0%; /* #000000 文字 */
|
||||
--card: 0 0% 100%; /* #ffffff 纯白卡片 */
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 26% 14%;
|
||||
/* Indigo primary (#6A6DFF) */
|
||||
--primary: 239 100% 71%;
|
||||
--popover-foreground: 0 0% 0%;
|
||||
|
||||
/* ── 主色 — 近黑(无品牌色,对标 skillhub-clone #202020) ── */
|
||||
--primary: 0 0% 13%; /* #202020 */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
/* Surface tones */
|
||||
--secondary: 210 30% 96%;
|
||||
--secondary-foreground: 220 26% 14%;
|
||||
--muted: 210 25% 95%;
|
||||
--muted-foreground: 215 14% 46%;
|
||||
/* Violet accent (#B85EFF) */
|
||||
--accent: 271 100% 68%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 55%;
|
||||
|
||||
/* ── 表面层 — 冷灰递进 ── */
|
||||
--secondary: 240 3% 97%; /* #f6f7f9 */
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 240 3% 94%; /* #eef0f2 */
|
||||
--muted-foreground: 0 0% 45%; /* #737373,保证辅助文字可读性 */
|
||||
|
||||
/* ── 强调色 — 淡蓝 hover(对齐 TabTin hsl(214 70% 96%)) ── */
|
||||
--accent: 214 70% 96%; /* 淡蓝 hover */
|
||||
--accent-foreground: 0 0% 7%; /* #111111 */
|
||||
|
||||
/* ── 功能色 ── */
|
||||
--destructive: 0 55% 48%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 239 100% 71%;
|
||||
--radius: 0.75rem;
|
||||
--border: 0 0% 92%; /* rgba(0,0,0,0.08) */
|
||||
--input: 240 3% 95%; /* #f2f3f5 */
|
||||
--ring: 0 0% 13%; /* #202020 */
|
||||
--radius: 0.625rem; /* 10px,对标 skillhub-clone */
|
||||
|
||||
/* Extended palette */
|
||||
--surface-glass: 210 30% 97%;
|
||||
--glow-primary: 239 100% 71%;
|
||||
--glow-accent: 271 100% 68%;
|
||||
--success: 160 60% 45%;
|
||||
--warning: 38 92% 58%;
|
||||
/* ── 扩展色板 ── */
|
||||
--surface-glass: 0 0% 100%;
|
||||
--glow-primary: 0 0% 13%;
|
||||
--glow-accent: 0 0% 7%;
|
||||
--success: 160 50% 40%;
|
||||
--warning: 38 85% 50%;
|
||||
|
||||
/* Brand */
|
||||
--brand-start: #6A6DFF;
|
||||
--brand-end: #B85EFF;
|
||||
--brand-gradient: linear-gradient(135deg, #6A6DFF 0%, #B85EFF 100%);
|
||||
/* ── 品牌渐变 — 无(纯黑) ── */
|
||||
--brand-start: #202020;
|
||||
--brand-end: #111111;
|
||||
--brand-gradient: none;
|
||||
|
||||
/* Text semantic */
|
||||
--text-secondary: 215 19% 35%;
|
||||
--text-muted: 215 14% 46%;
|
||||
--text-placeholder: 213 12% 63%;
|
||||
/* ── 文字语义色 — 对标 clone 的 rgba(0,0,0,0.62) / 0.4 ── */
|
||||
--text-secondary: 0 0% 45%; /* #737373,正文辅助信息 */
|
||||
--text-muted: 0 0% 38%;
|
||||
--text-placeholder: 0 0% 45%;
|
||||
|
||||
/* Border semantic */
|
||||
--border-card: 220 26% 94%;
|
||||
/* ── 边框语义 ── */
|
||||
--border-card: 0 0% 94%; /* rgba(0,0,0,0.06) */
|
||||
|
||||
/* ── 阴影 token — 极轻,对标 clone 的 0 1px 3px rgba(0,0,0,0.04) ── */
|
||||
--shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04);
|
||||
--shadow-card-hover: 0 4px 12px 0 rgb(0 0 0 / 0.08);
|
||||
--shadow-btn-primary: 0 1px 2px 0 rgb(0 0 0 / 0.06);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Cool ink surfaces: distinct depth without near-black dead zones. */
|
||||
--background: 222 30% 10%;
|
||||
--foreground: 216 28% 93%;
|
||||
--card: 222 25% 14%;
|
||||
--card-foreground: 216 28% 93%;
|
||||
--popover: 222 24% 15%;
|
||||
--popover-foreground: 216 28% 93%;
|
||||
--primary: 241 92% 74%;
|
||||
--primary-foreground: 222 30% 10%;
|
||||
--secondary: 222 20% 18%;
|
||||
--secondary-foreground: 216 24% 88%;
|
||||
--muted: 222 18% 19%;
|
||||
--muted-foreground: 216 14% 66%;
|
||||
--accent: 272 86% 72%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 52%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 222 16% 25%;
|
||||
--input: 222 16% 25%;
|
||||
--ring: 241 92% 74%;
|
||||
--surface-glass: 222 24% 14%;
|
||||
--glow-primary: 241 92% 70%;
|
||||
--glow-accent: 272 80% 68%;
|
||||
--text-secondary: 216 17% 76%;
|
||||
--text-muted: 216 14% 66%;
|
||||
--text-placeholder: 216 12% 54%;
|
||||
--border-card: 222 16% 24%;
|
||||
/* ── 基础层 — 暗色保持近黑风格,但提高卡片对比度以改善 hover 可见性 ── */
|
||||
--background: 0 0% 6%; /* #0f0f0f */
|
||||
--foreground: 0 0% 94%; /* 更亮的文字 */
|
||||
--card: 0 0% 10%; /* #1a1a1a 稍微亮一点 */
|
||||
--card-foreground: 0 0% 94%;
|
||||
--popover: 0 0% 11%;
|
||||
--popover-foreground: 0 0% 94%;
|
||||
|
||||
/* ── 主色 — 白(暗色下反转) ── */
|
||||
--primary: 0 0% 94%; /* 近白 */
|
||||
--primary-foreground: 0 0% 6%; /* 近黑 */
|
||||
|
||||
/* ── 表面层 — 灰阶递进,提高对比度 ── */
|
||||
--secondary: 0 0% 14%; /* 从 12% 提升 */
|
||||
--secondary-foreground: 0 0% 90%;
|
||||
--muted: 0 0% 18%; /* 从 15% 提升 */
|
||||
--muted-foreground: 0 0% 68%; /* 从 60% 提升,图标更清晰 */
|
||||
|
||||
/* ── 强调色 — hover 更明显 ── */
|
||||
--accent: 0 0% 22%; /* 从 18% 提升 */
|
||||
--accent-foreground: 0 0% 85%;
|
||||
|
||||
/* ── 功能色 ── */
|
||||
--destructive: 0 45% 48%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 20%; /* 从 18% 提升 */
|
||||
--input: 0 0% 16%;
|
||||
--ring: 0 0% 85%;
|
||||
|
||||
/* ── 扩展色板 ── */
|
||||
--surface-glass: 0 0% 10%;
|
||||
--glow-primary: 0 0% 80%;
|
||||
--glow-accent: 0 0% 60%;
|
||||
--success: 160 45% 42%;
|
||||
--warning: 38 80% 48%;
|
||||
|
||||
/* ── 文字语义 — 提高可读性 ── */
|
||||
--text-secondary: 0 0% 75%; /* 从 70% 提升 */
|
||||
--text-muted: 0 0% 68%; /* 从 60% 提升 */
|
||||
--text-placeholder: 0 0% 55%;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--border-card: 0 0% 18%; /* 从 16% 提升 */
|
||||
|
||||
/* ── 阴影 ── */
|
||||
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.30);
|
||||
--shadow-card-hover: 0 4px 12px 0 rgb(0 0 0 / 0.40);
|
||||
--shadow-btn-primary: 0 1px 2px 0 rgb(0 0 0 / 0.20);
|
||||
}
|
||||
|
||||
/* 暗色模式 card-hover */
|
||||
.dark .card-hover:hover {
|
||||
background: hsl(0 0% 20%);
|
||||
border-color: hsl(0 0% 25%);
|
||||
box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.30);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -92,11 +136,27 @@
|
|||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Text', 'PingFang SC', 'HarmonyOS Sans SC', 'Noto Sans SC', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Syne', 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Display', 'PingFang SC', 'HarmonyOS Sans SC', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Hero 标题负 letter-spacing */
|
||||
.hero-title {
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code, pre, kbd {
|
||||
|
|
@ -211,6 +271,25 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.scroll-fade-up {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease both;
|
||||
}
|
||||
|
|
@ -258,25 +337,12 @@
|
|||
}
|
||||
|
||||
.dark .text-gradient-hero {
|
||||
background: linear-gradient(135deg, #8183FF, #C77EFF);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
background: none;
|
||||
-webkit-text-fill-color: unset;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ─── Card hover lift ─── */
|
||||
.card-hover {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px -12px hsl(var(--primary) / 0.1),
|
||||
0 8px 16px -8px hsl(0 0% 0% / 0.2);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
/* ─── Card hover lift(已移除,由下方浅蓝灰 hover 替代) ─── */
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar {
|
||||
|
|
@ -478,8 +544,8 @@
|
|||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--brand-start);
|
||||
background: rgba(106, 109, 255, 0.04);
|
||||
border-color: #202020;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.upload-zone .upload-zone-icon {
|
||||
|
|
@ -488,15 +554,150 @@
|
|||
}
|
||||
|
||||
.upload-zone:hover .upload-zone-icon {
|
||||
color: var(--brand-start);
|
||||
color: #202020;
|
||||
}
|
||||
|
||||
/* ─── Feature icon shadow ─── */
|
||||
.feature-icon {
|
||||
box-shadow: 0 14px 30px rgba(106, 109, 255, 0.45);
|
||||
box-shadow: 0 14px 30px hsl(0 0% 0% / 0.12);
|
||||
}
|
||||
|
||||
/* ─── Hero input placeholder ─── */
|
||||
.hero-input::placeholder {
|
||||
color: hsl(var(--text-placeholder));
|
||||
}
|
||||
|
||||
/* 技能卡片描述固定为三行,避免长文案拉高网格;完整内容通过 title 提供。 */
|
||||
.skill-card-summary {
|
||||
display: -webkit-box;
|
||||
min-height: 3.75rem;
|
||||
max-height: 3.75rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
样式细节增强(不改颜色,只改交互/布局/字体)
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ─── 胶囊按钮(白 + 近黑风格) ─── */
|
||||
.btn-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
transition: transform 160ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.btn-pill:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.btn-pill-primary {
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 16%) inset, 0 4px 12px rgb(0 0 0 / 8%);
|
||||
}
|
||||
.btn-pill-primary:hover {
|
||||
background: #111111;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 16%) inset, 0 7px 18px rgb(0 0 0 / 12%);
|
||||
}
|
||||
.btn-pill-primary:active {
|
||||
background: #000;
|
||||
transform: translateY(0) scale(0.985);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
|
||||
}
|
||||
.btn-pill-outline {
|
||||
background: #f2f2f2;
|
||||
color: #000;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-pill-outline:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: rgb(0 0 0 / 6%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-pill-outline:active {
|
||||
background: #dedede;
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-pill,
|
||||
.btn-pill:hover,
|
||||
.btn-pill:active {
|
||||
transform: none;
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 多彩标签(柔和彩色,不改品牌色) ─── */
|
||||
.tag-color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-orange { background: hsl(30 70% 90%); color: hsl(25 70% 28%); }
|
||||
.tag-purple { background: hsl(270 45% 92%); color: hsl(270 40% 32%); }
|
||||
.tag-blue { background: hsl(215 60% 92%); color: hsl(215 55% 30%); }
|
||||
.tag-green { background: hsl(155 50% 90%); color: hsl(155 55% 24%); }
|
||||
|
||||
.dark .tag-orange { background: hsl(25 45% 18%); color: hsl(30 70% 72%); }
|
||||
.dark .tag-purple { background: hsl(270 30% 20%); color: hsl(270 45% 80%); }
|
||||
.dark .tag-blue { background: hsl(215 40% 18%); color: hsl(215 50% 76%); }
|
||||
.dark .tag-green { background: hsl(155 40% 16%); color: hsl(155 50% 68%); }
|
||||
|
||||
/* ─── Card hover(对齐 TabTin hsl(214 70% 96%),无位移) ─── */
|
||||
.card-hover {
|
||||
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
.card-hover:hover {
|
||||
background: hsl(214 70% 96%);
|
||||
box-shadow: 0 4px 12px 0 rgb(0 0 0 / 0.06);
|
||||
border-color: hsl(214 50% 88%);
|
||||
}
|
||||
|
||||
/* ─── Container / 选中项模型(淡蓝 tint,对齐 TabTin) ─── */
|
||||
.container-tray {
|
||||
background: hsl(214 70% 97%);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.container-tray .item-selected {
|
||||
background: #fff;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.04);
|
||||
}
|
||||
.container-tray .item-selected .text-foreground,
|
||||
.container-tray .item-selected [style*="color: hsl(var(--foreground))"] {
|
||||
color: hsl(var(--foreground)) !important;
|
||||
}
|
||||
.container-tray .item-idle {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.container-tray .item-idle:hover {
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* 暗色模式 container-tray */
|
||||
.dark .container-tray {
|
||||
background: hsl(0 0% 13%);
|
||||
}
|
||||
.dark .container-tray .item-selected {
|
||||
background: hsl(0 0% 18%);
|
||||
border-color: rgba(255,255,255,0.08);
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.30);
|
||||
}
|
||||
.dark .container-tray .item-idle:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,16 +62,11 @@ describe('DashboardPage', () => {
|
|||
expect(typeof DashboardPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the dashboard title and user info section', () => {
|
||||
it('renders the dashboard sidebar and overview cards', () => {
|
||||
const html = renderToStaticMarkup(<DashboardPage />)
|
||||
|
||||
expect(html).toContain('dashboard.title')
|
||||
expect(html).toContain('dashboard.userInfo')
|
||||
})
|
||||
|
||||
it('shows the my-skills preview section', () => {
|
||||
const html = renderToStaticMarkup(<DashboardPage />)
|
||||
|
||||
expect(html).toContain('mySkills.title')
|
||||
expect(html).toContain('sidebar.skillsAndData')
|
||||
expect(html).toContain('overview.mySkills')
|
||||
expect(html).not.toContain('overview.publish')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,181 +1,218 @@
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { useMySkills } from '@/shared/hooks/use-user-queries'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle'
|
||||
import { TokenList } from '@/features/token/token-list'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
|
||||
import { limitPreviewItems } from './dashboard-preview'
|
||||
|
||||
const DASHBOARD_PREVIEW_LIMIT = 5
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import {
|
||||
Star, Heart, Package, Key, Shield, Flag, Globe,
|
||||
UserCog, Lock, Bell, Clock, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Default dashboard landing page for authenticated users.
|
||||
* Sidebar navigation groups for the Dashboard.
|
||||
*
|
||||
* It surfaces account context, quick links, and a lightweight preview of the user's latest skills
|
||||
* and tokens before they move into more specialized dashboard sub-pages.
|
||||
* Groups are rendered with a label divider; items within each group share visual spacing.
|
||||
* Admin-only items are filtered based on the user's platform roles.
|
||||
*/
|
||||
|
||||
interface SidebarItem {
|
||||
key: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
to: string
|
||||
admin?: boolean
|
||||
passwordCapability?: boolean
|
||||
badge?: string
|
||||
}
|
||||
|
||||
interface SidebarGroup {
|
||||
label?: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
|
||||
export const SIDEBAR_GROUPS: SidebarGroup[] = [
|
||||
{
|
||||
label: 'sidebar.account',
|
||||
items: [
|
||||
{ key: 'profile', icon: UserCog, label: 'sidebar.profile', to: '/settings/profile' },
|
||||
{ key: 'security', icon: Lock, label: 'sidebar.security', to: '/settings/security', passwordCapability: true },
|
||||
{ key: 'notifications', icon: Bell, label: 'sidebar.notifications', to: '/settings/notifications' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'sidebar.skillsAndData',
|
||||
items: [
|
||||
{ key: 'skills', icon: Package, label: 'sidebar.mySkills', to: '/dashboard/skills' },
|
||||
{ key: 'namespaces', icon: Globe, label: 'sidebar.namespaces', to: '/dashboard/namespaces' },
|
||||
{ key: 'stars', icon: Star, label: 'sidebar.stars', to: '/dashboard/stars' },
|
||||
{ key: 'subscriptions', icon: Heart, label: 'sidebar.subscriptions', to: '/dashboard/subscriptions' },
|
||||
{ key: 'tokens', icon: Key, label: 'sidebar.tokens', to: '/dashboard/tokens' },
|
||||
{ key: 'reviewProgress', icon: Clock, label: 'sidebar.reviewProgress', to: '/dashboard/review-progress' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'sidebar.admin',
|
||||
items: [
|
||||
{ key: 'governance', icon: Shield, label: 'sidebar.governance', to: '/dashboard/governance', admin: true },
|
||||
{ key: 'reports', icon: Flag, label: 'sidebar.reports', to: '/dashboard/reports', admin: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Flatten for backward compatibility with layout.tsx
|
||||
const SIDEBAR_NAV_ITEMS = SIDEBAR_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
export const SIDEBAR_NAV = SIDEBAR_NAV_ITEMS.map(({ key, icon, label, to, admin, passwordCapability }) => ({
|
||||
key,
|
||||
icon,
|
||||
label,
|
||||
to,
|
||||
admin,
|
||||
passwordCapability,
|
||||
exact: false,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Overview cards for the Dashboard home.
|
||||
* Shown as a grid of quick-access cards linking to main sections.
|
||||
*/
|
||||
const OVERVIEW_CARDS = [
|
||||
{ key: 'skills', icon: Package, label: 'overview.mySkills', to: '/dashboard/skills', desc: 'overview.mySkillsDesc' },
|
||||
{ key: 'tokens', icon: Key, label: 'overview.tokens', to: '/dashboard/tokens', desc: 'overview.tokensDesc' },
|
||||
{ key: 'stars', icon: Star, label: 'overview.stars', to: '/dashboard/stars', desc: 'overview.starsDesc' },
|
||||
{ key: 'profile', icon: UserCog, label: 'overview.profile', to: '/settings/profile', desc: 'overview.profileDesc' },
|
||||
{ key: 'security', icon: Lock, label: 'overview.security', to: '/settings/security', desc: 'overview.securityDesc' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Dashboard home page with sidebar + overview cards.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const skillPreviewPageSize = DASHBOARD_PREVIEW_LIMIT
|
||||
const { t } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
|
||||
const { data: skillPage, isLoading: isLoadingSkills } = useMySkills({ page: 0, size: skillPreviewPageSize })
|
||||
const skillPreview = limitPreviewItems<SkillSummary>(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT)
|
||||
|
||||
const filteredGroups = SIDEBAR_GROUPS
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => (
|
||||
(!item.admin || governanceVisible)
|
||||
&& (!item.passwordCapability || user?.canChangePassword === true)
|
||||
)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
|
||||
return (
|
||||
<div className={APP_SHELL_PAGE_CLASS_NAME}>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold" style={{ color: 'hsl(var(--foreground))' }}>{t('dashboard.title')}</h1>
|
||||
<p className="mt-2 text-lg" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<DashboardPageHeader title={t('dashboard.title')} subtitle={t('dashboard.subtitle')} />
|
||||
{/* Two-column layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Left sidebar */}
|
||||
<DashboardSidebar groups={filteredGroups} user={user} t={t} pathname="/dashboard" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('dashboard.userInfo')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.userInfoDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
{user?.avatarUrl && (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
className="h-20 w-20 rounded-2xl border-2 border-border/60 shadow-card"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xl font-semibold font-heading">{user?.displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.userId')}: {user?.userId}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
{t('dashboard.loginVia', { provider: user?.oauthProvider })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.platformRoles && user.platformRoles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium font-heading">{t('dashboard.platformRoles')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.platformRoles.map((role: string) => (
|
||||
<span
|
||||
key={role}
|
||||
className="role-pill"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className={`grid grid-cols-1 gap-4 ${governanceVisible ? 'md:grid-cols-5' : 'md:grid-cols-4'}`}>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.starsAndRatings')}</div>
|
||||
<Link to="/dashboard/stars" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewStars')}
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.subscriptions')}</div>
|
||||
<Link to="/dashboard/subscriptions" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewSubscriptions')}
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.mySkillsTitle')}</div>
|
||||
<Link to="/dashboard/skills" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.openMySkills')}
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.credentials')}</div>
|
||||
<Link to="/dashboard/tokens" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.openTokens')}
|
||||
</Link>
|
||||
</Card>
|
||||
{governanceVisible ? (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.governanceTitle')}</div>
|
||||
<Link to="/dashboard/governance" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewGovernance')}
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
{governanceVisible ? (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.reportsTitle')}</div>
|
||||
<Link to="/dashboard/reports" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewReports')}
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('mySkills.title')}</h2>
|
||||
<Link to="/dashboard/skills" className="text-sm font-semibold text-primary hover:underline">
|
||||
{t('dashboard.openMySkills')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('dashboard.mySkillsPreviewDescription')}</p>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
{isLoadingSkills ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: DASHBOARD_PREVIEW_LIMIT + 1 }).map((_, index) => (
|
||||
<div key={index} className="h-20 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : skillPreview.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{skillPreview.items.map((skill) => (
|
||||
<Link
|
||||
key={skill.id}
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: skill.namespace, slug: skill.slug }}
|
||||
className="rounded-lg border border-border/60 px-3 py-3 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{skill.displayName}</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">@{skill.namespace}</div>
|
||||
{getHeadlineVersion(skill) ? (
|
||||
<div className="mt-2 inline-flex rounded-full bg-secondary px-2 py-1 text-xs text-muted-foreground">
|
||||
v{getHeadlineVersion(skill)?.version}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
to="/dashboard/skills"
|
||||
className="flex min-h-20 flex-col items-center justify-center rounded-lg border border-dashed border-border/70 px-3 py-3 text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<span className="text-lg font-semibold leading-none">{t('dashboard.previewMore')}</span>
|
||||
<span className="mt-2 text-xs font-medium">{t('dashboard.previewMoreLabel')}</span>
|
||||
</Link>
|
||||
{/* Right content - overview cards */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{OVERVIEW_CARDS.filter((card) => card.key !== 'security' || user?.canChangePassword === true).map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={card.to}
|
||||
className="group flex items-start gap-4 rounded-xl border border-border/60 p-5 transition-all duration-150 hover:bg-accent hover:border-border hover:shadow-sm"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center bg-secondary">
|
||||
<Icon className="w-5 h-5" style={{ color: 'hsl(var(--foreground))' }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.mySkillsPreviewEmpty')}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TokenList />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t(card.label)}
|
||||
</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 opacity-0 -translate-x-1 transition-all duration-150 group-hover:opacity-100 group-hover:translate-x-0" style={{ color: 'hsl(var(--muted-foreground))' }} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t(card.desc)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Reusable sidebar for dashboard and its sub-pages. */
|
||||
export function DashboardSidebar({
|
||||
groups,
|
||||
user,
|
||||
t,
|
||||
pathname,
|
||||
}: {
|
||||
groups: SidebarGroup[]
|
||||
user: ReturnType<typeof useAuth>['user']
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
pathname: string
|
||||
}) {
|
||||
return (
|
||||
<aside className="w-full lg:w-56 flex-shrink-0">
|
||||
<div className="lg:sticky lg:top-[68px]">
|
||||
{/* User summary */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 mb-4">
|
||||
{user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} className="h-8 w-8 rounded-full border border-border/60" />
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-secondary flex items-center justify-center text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{user?.displayName?.charAt(0) ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{user?.displayName}
|
||||
</div>
|
||||
<div className="text-xs truncate" style={{ color: 'hsl(var(--muted-foreground))' }}>
|
||||
{user?.email}
|
||||
</div>
|
||||
<div className="text-xs truncate" style={{ color: 'hsl(var(--muted-foreground))' }}>
|
||||
{t('dashboard.userId')}: {user?.userId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav groups */}
|
||||
{groups.map((group) => (
|
||||
<div key={group.label ?? 'top'} className="mb-4">
|
||||
{group.label && (
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'hsl(var(--muted-foreground))' }}>
|
||||
{t(group.label)}
|
||||
</div>
|
||||
)}
|
||||
<nav className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(item.to)
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
isActive ? 'bg-accent' : 'hover:bg-accent'
|
||||
}`}
|
||||
style={{ color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--text-secondary))' }}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
|||
useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }),
|
||||
useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }),
|
||||
useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }),
|
||||
useMyNamespacesPage: () => ({
|
||||
data: { items: mockNamespaces, total: mockNamespaces.length, page: 0, size: 10 },
|
||||
isLoading: false,
|
||||
}),
|
||||
useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }),
|
||||
useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/shared/ui/button'
|
|||
import { Card } from '@/shared/ui/card'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
useArchiveNamespace,
|
||||
useDeleteNamespace,
|
||||
useFreezeNamespace,
|
||||
useMyNamespaces,
|
||||
useMyNamespacesPage,
|
||||
useRestoreNamespace,
|
||||
useUnfreezeNamespace,
|
||||
} from '@/shared/hooks/use-namespace-queries'
|
||||
|
|
@ -144,13 +145,24 @@ export async function executeNamespaceAction(
|
|||
* namespace lifecycle actions because each action combines permissions, copy,
|
||||
* and optimistic follow-up behavior that are specific to this route.
|
||||
*/
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export function MyNamespacesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const [pendingAction, setPendingAction] = useState<PendingNamespaceAction | null>(null)
|
||||
const { data: namespaces, isLoading } = useMyNamespaces()
|
||||
const [page, setPage] = useState(0)
|
||||
const { data: namespacePage, isLoading } = useMyNamespacesPage({ page, size: PAGE_SIZE })
|
||||
const namespaces = namespacePage?.items ?? []
|
||||
const totalPages = namespacePage ? Math.max(Math.ceil(namespacePage.total / namespacePage.size), 1) : 1
|
||||
|
||||
useEffect(() => {
|
||||
if (page >= totalPages) {
|
||||
setPage(Math.max(totalPages - 1, 0))
|
||||
}
|
||||
}, [page, totalPages])
|
||||
const freezeMutation = useFreezeNamespace()
|
||||
const unfreezeMutation = useUnfreezeNamespace()
|
||||
const archiveMutation = useArchiveNamespace()
|
||||
|
|
@ -248,44 +260,43 @@ export function MyNamespacesPage() {
|
|||
/>
|
||||
|
||||
{namespaces && namespaces.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{namespaces.map((namespace, idx) => (
|
||||
<Card
|
||||
key={namespace.id}
|
||||
data-testid={`namespace-card-${namespace.slug}`}
|
||||
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
className={`flex h-full flex-col p-5 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
|
||||
onClick={() => handleNamespaceClick(namespace.slug)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-semibold font-heading text-lg group-hover:text-primary transition-colors">
|
||||
<div className="flex h-full flex-col gap-5">
|
||||
<div className="flex flex-1 items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold font-heading text-lg leading-tight group-hover:text-primary transition-colors">
|
||||
{namespace.displayName}
|
||||
</h3>
|
||||
<NamespaceBadge
|
||||
type={namespace.type}
|
||||
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
|
||||
/>
|
||||
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
|
||||
{resolveStatusLabel(namespace.status)}
|
||||
</span>
|
||||
</div>
|
||||
{namespace.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
|
||||
{namespace.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
|
||||
<div className="mt-3 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<p className="mb-3 min-h-10 text-sm leading-relaxed text-muted-foreground">
|
||||
{namespace.description ?? ''}
|
||||
</p>
|
||||
<div className="text-sm font-mono text-muted-foreground">@{namespace.slug}</div>
|
||||
<div className="mt-4 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{resolveHint(namespace.status, namespace.type)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
|
||||
<div className="mt-3 border-t border-border/50 pt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
|
||||
{t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-wrap gap-2 border-t border-border/50 pt-4">
|
||||
{namespace.type === 'TEAM' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -368,6 +379,10 @@ export function MyNamespacesPage() {
|
|||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 ? (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={t('myNamespaces.emptyTitle')}
|
||||
|
|
|
|||
|
|
@ -306,11 +306,6 @@ export function MySkillsPage() {
|
|||
<DashboardPageHeader
|
||||
title={t('mySkills.title')}
|
||||
subtitle={t('mySkills.subtitle')}
|
||||
actions={(
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
{t('mySkills.publishNew')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
@ -542,11 +537,7 @@ export function MySkillsPage() {
|
|||
<Button size="lg" variant="outline" onClick={handleClearSearch}>
|
||||
{t('mySkills.clearSearch')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" onClick={() => navigate({ to: '/dashboard/publish' })}>
|
||||
{t('mySkills.publishSkill')}
|
||||
</Button>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -155,36 +155,32 @@ export function PublishPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-up">
|
||||
<div className="mx-auto max-w-2xl space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('publish.title')} subtitle={t('publish.subtitle')} />
|
||||
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion ? (
|
||||
<Card className="border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('publish.resubmitNotice.title', {
|
||||
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
|
||||
version: prefill.resubmitVersion,
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('publish.resubmitNotice.description')}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="p-4 bg-blue-500/5 border-blue-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<Card className="space-y-8 p-6 md:p-8">
|
||||
{prefill.resubmitSkill && prefill.resubmitVersion ? (
|
||||
<div className="rounded-xl border border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{t('publish.resubmitNotice.title', {
|
||||
skill: `@${prefill.namespace}/${prefill.resubmitSkill}`,
|
||||
version: prefill.resubmitVersion,
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('publish.resubmitNotice.description')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('publish.reviewNotice.title')}</h3>
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">{t('publish.reviewNotice.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('publish.reviewNotice.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-8 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="namespace" className="text-sm font-semibold font-heading">{t('publish.namespace')}</Label>
|
||||
{isLoadingNamespaces ? (
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
|
|||
vi.mock('@/features/report/use-skill-reports', () => ({
|
||||
useDismissSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useResolveSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useSkillReports: () => ({ data: [], isLoading: false }),
|
||||
useSkillReports: () => ({ data: { items: [], total: 0, page: 0, size: 10 }, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/report/report-text', () => ({
|
||||
|
|
@ -56,10 +56,16 @@ vi.mock('@/shared/lib/toast', () => ({
|
|||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
import { ReportsPage } from './reports'
|
||||
import { clampReportPage, ReportsPage } from './reports'
|
||||
|
||||
describe('ReportsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ReportsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('moves an out-of-range page back after the last item is handled', () => {
|
||||
expect(clampReportPage(2, { total: 20, size: 10 })).toBe(1)
|
||||
expect(clampReportPage(1, { total: 0, size: 10 })).toBe(0)
|
||||
expect(clampReportPage(1, { total: 25, size: 10 })).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
|
@ -12,6 +13,16 @@ import { REPORT_TEXT_WRAP_CLASS_NAME } from '@/features/report/report-text'
|
|||
import { toast } from '@/shared/lib/toast'
|
||||
import type { ReportDisposition } from '@/api/types'
|
||||
|
||||
interface ReportPageMetadata {
|
||||
total: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export function clampReportPage(page: number, reports?: ReportPageMetadata): number {
|
||||
if (!reports) return page
|
||||
return Math.min(page, Math.max(Math.ceil(reports.total / reports.size) - 1, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation page for skill reports. The route keeps the confirmation state
|
||||
* because different resolution dispositions map to different user-facing copy
|
||||
|
|
@ -26,12 +37,40 @@ export function ReportsPage() {
|
|||
disposition?: ReportDisposition
|
||||
skillLabel: string
|
||||
} | null>(null)
|
||||
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING')
|
||||
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED')
|
||||
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED')
|
||||
const [pages, setPages] = useState<Record<'PENDING' | 'RESOLVED' | 'DISMISSED', number>>({
|
||||
PENDING: 0,
|
||||
RESOLVED: 0,
|
||||
DISMISSED: 0,
|
||||
})
|
||||
const pageSize = 10
|
||||
const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING', pages.PENDING, pageSize)
|
||||
const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED', pages.RESOLVED, pageSize)
|
||||
const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED', pages.DISMISSED, pageSize)
|
||||
const resolveMutation = useResolveSkillReport()
|
||||
const dismissMutation = useDismissSkillReport()
|
||||
|
||||
useEffect(() => {
|
||||
const totals = {
|
||||
PENDING: pendingReports,
|
||||
RESOLVED: resolvedReports,
|
||||
DISMISSED: dismissedReports,
|
||||
}
|
||||
setPages((current) => {
|
||||
const next = { ...current }
|
||||
let changed = false
|
||||
for (const status of ['PENDING', 'RESOLVED', 'DISMISSED'] as const) {
|
||||
const reports = totals[status]
|
||||
if (!reports) continue
|
||||
const clampedPage = clampReportPage(next[status], reports)
|
||||
if (next[status] !== clampedPage) {
|
||||
next[status] = clampedPage
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : current
|
||||
})
|
||||
}, [dismissedReports, pendingReports, resolvedReports])
|
||||
|
||||
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
|
||||
|
||||
const handleOpenSkill = (namespace?: string, skillSlug?: string) => {
|
||||
|
|
@ -77,13 +116,13 @@ export function ReportsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!reports || reports.length === 0) {
|
||||
if (!reports || reports.items.length === 0) {
|
||||
return <Card className="p-12 text-center text-muted-foreground">{t('reports.empty')}</Card>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{reports.map((report) => {
|
||||
{reports.items.map((report) => {
|
||||
const skillLabel = report.skillDisplayName || report.skillSlug || `#${report.skillId}`
|
||||
return (
|
||||
<Card key={report.id} className="p-5 space-y-4">
|
||||
|
|
@ -149,6 +188,13 @@ export function ReportsPage() {
|
|||
</Card>
|
||||
)
|
||||
})}
|
||||
{reports.total > reports.size ? (
|
||||
<Pagination
|
||||
page={pages[status]}
|
||||
totalPages={Math.max(Math.ceil(reports.total / reports.size), 1)}
|
||||
onPageChange={(nextPage) => setPages((current) => ({ ...current, [status]: nextPage }))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ function ProgressItem({
|
|||
<Link
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: item.namespace, slug: item.skillSlug }}
|
||||
search={{ returnTo: '/dashboard/review-progress' }}
|
||||
className="truncate font-semibold text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
@{item.namespace}/{item.skillSlug}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SkeletonList } from '@/shared/components/skeleton-loader'
|
|||
import { QuickStartSection } from '@/shared/components/quick-start'
|
||||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
import { BrandMark } from '@/shared/components/brand-mark'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export function HomePage() {
|
||||
|
|
@ -35,7 +36,8 @@ export function HomePage() {
|
|||
{/* Hero Section */}
|
||||
<div className="text-center space-y-8 py-16 animate-fade-up">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold text-brand-gradient leading-tight">
|
||||
<BrandMark className="mx-auto h-16 w-16 rounded-2xl bg-background shadow-sm ring-1 ring-border/70 md:h-20 md:w-20" />
|
||||
<h1 className="text-6xl md:text-7xl lg:text-8xl font-bold leading-tight" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
SkillHub
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl max-w-2xl mx-auto" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
|
|
@ -52,7 +54,7 @@ export function HomePage() {
|
|||
|
||||
<div className="flex items-center justify-center gap-4 animate-fade-up delay-2">
|
||||
<button
|
||||
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-brand-gradient shadow-sm hover:opacity-95 transition-opacity"
|
||||
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-[#202020] shadow-sm hover:bg-[#111] transition-colors"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('home.browseSkills')}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,19 @@ vi.mock('react-i18next', async () => {
|
|||
})
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
ArrowRight: () => null,
|
||||
CheckCircle2: () => null,
|
||||
Clock3: () => null,
|
||||
Copy: () => null,
|
||||
PackageOpen: () => null,
|
||||
Terminal: () => null,
|
||||
Shield: () => null,
|
||||
Users: () => null,
|
||||
GitBranch: () => null,
|
||||
Lock: () => null,
|
||||
Monitor: () => null,
|
||||
Search: () => null,
|
||||
Server: () => null,
|
||||
Settings: () => null,
|
||||
}))
|
||||
|
||||
|
|
@ -68,6 +75,6 @@ describe('LandingPage', () => {
|
|||
const html = renderToStaticMarkup(<LandingPage />)
|
||||
|
||||
expect(html).toContain('SkillHub')
|
||||
expect(html).toContain('landing.hero.title')
|
||||
expect(html).toContain('landing.experience.heroTitle')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
import { PackageOpen, Terminal, Shield, Users, GitBranch, Search as SearchIcon, Settings } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
GitBranch,
|
||||
Lock,
|
||||
Monitor,
|
||||
PackageOpen,
|
||||
Search as SearchIcon,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Terminal,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { BrandMark } from '@/shared/components/brand-mark'
|
||||
import { LandingQuickStartSection } from '@/shared/components/landing-quick-start'
|
||||
import { SkillCard } from '@/features/skill/skill-card'
|
||||
import { SkeletonList } from '@/shared/components/skeleton-loader'
|
||||
|
|
@ -9,6 +24,230 @@ import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
|||
import { useInView } from '@/shared/hooks/use-in-view'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface HeroSkillItem {
|
||||
name: string
|
||||
namespace: string
|
||||
summaryKey: string
|
||||
version: string
|
||||
badgeClassName: string
|
||||
statusClassName: string
|
||||
}
|
||||
|
||||
const HERO_SKILLS: HeroSkillItem[] = [
|
||||
{
|
||||
name: 'weather',
|
||||
namespace: 'global',
|
||||
summaryKey: 'landing.experience.demoSkills.weather',
|
||||
version: '1.3.0',
|
||||
badgeClassName: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300',
|
||||
statusClassName: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
name: 'git-helper',
|
||||
namespace: 'devtools',
|
||||
summaryKey: 'landing.experience.demoSkills.gitHelper',
|
||||
version: '2.1.0',
|
||||
badgeClassName: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-300',
|
||||
statusClassName: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
name: 'diagram-maker',
|
||||
namespace: 'global',
|
||||
summaryKey: 'landing.experience.demoSkills.diagramMaker',
|
||||
version: '0.9.2',
|
||||
badgeClassName: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300',
|
||||
statusClassName: 'bg-amber-500',
|
||||
},
|
||||
{
|
||||
name: 'code-reviewer',
|
||||
namespace: 'devtools',
|
||||
summaryKey: 'landing.experience.demoSkills.codeReviewer',
|
||||
version: '1.5.0',
|
||||
badgeClassName: 'bg-cyan-50 text-cyan-600 dark:bg-cyan-500/10 dark:text-cyan-300',
|
||||
statusClassName: 'bg-emerald-500',
|
||||
},
|
||||
]
|
||||
|
||||
function HeroBrowserMockup({ onSearch }: { onSearch: (query: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 lg:mt-0">
|
||||
<div className="absolute -inset-3 -z-10 hidden rotate-1 rounded-2xl bg-secondary lg:block" />
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-[var(--shadow-card)]">
|
||||
<div className="flex items-center justify-between border-b border-border/70 bg-secondary/70 px-4 py-3">
|
||||
<div className="flex items-center gap-2" aria-hidden>
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
|
||||
</div>
|
||||
<span className="rounded-full bg-background px-3 py-1 text-[11px] font-medium text-muted-foreground ring-1 ring-border/70">
|
||||
Skill Registry
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[360px] flex-col p-5">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-lg border border-border/70 bg-secondary/70 px-3 py-2 transition-colors focus-within:border-ring focus-within:bg-background">
|
||||
<SearchIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('landing.hero.searchPlaceholder')}
|
||||
className="min-w-0 flex-1 border-none bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSearch((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{HERO_SKILLS.map((skill) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background p-3 text-left transition-[transform,border-color,background-color,box-shadow] duration-150 hover:-translate-y-px hover:border-border hover:bg-secondary/60 hover:shadow-sm active:translate-y-0 active:scale-[0.995] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none"
|
||||
onClick={() => onSearch(skill.name)}
|
||||
>
|
||||
<span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-bold ${skill.badgeClassName}`}>
|
||||
{skill.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{skill.name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">@{skill.namespace}</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{t(skill.summaryKey)}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-right">
|
||||
<span className={`mb-1 inline-block h-2 w-2 rounded-full ${skill.statusClassName}`} />
|
||||
<span className="block font-mono text-xs text-muted-foreground">v{skill.version}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-border/70 pt-3">
|
||||
<span className="text-xs text-muted-foreground">{t('landing.experience.demoSkills.caption')}</span>
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{t('landing.experience.browseAll')} <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-3 -right-3 hidden items-center gap-3 rounded-xl border border-border/70 bg-background p-3 shadow-lg sm:flex">
|
||||
<BrandMark className="h-9 w-9 rounded-lg bg-background ring-1 ring-border/70" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{t('landing.hero.publishSkill')}</div>
|
||||
<div className="text-xs text-muted-foreground">Web · CLI · Agent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnterpriseSection() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className="w-full bg-background px-6 py-16 md:py-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="text-center mb-12">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">Enterprise</p>
|
||||
<h2 className="mb-3 text-2xl font-medium tracking-tight text-foreground md:text-3xl">{t('landing.experience.enterprise.title')}</h2>
|
||||
<p className="text-muted-foreground">{t('landing.experience.enterprise.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-2 md:row-span-2 flex flex-col rounded-xl border border-border/70 bg-card p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-900 text-white">
|
||||
<Server className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">{t('landing.experience.enterprise.deployment.title')}</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-sm leading-relaxed text-muted-foreground">
|
||||
{t('landing.experience.enterprise.deployment.description')}
|
||||
</p>
|
||||
<div className="flex-1 rounded-lg border border-border/70 bg-secondary/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||
<div className="mb-0.5 text-center text-[10px] text-muted-foreground">{t('landing.experience.enterprise.clientLayer')}</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Agent</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">CLI</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Web</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<svg className="h-4 w-16 text-border" fill="none" stroke="currentColor" viewBox="0 0 64 16"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" strokeDasharray="3 3" d="M0 8h54" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M50 4l6 4-6 4" /></svg>
|
||||
</div>
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="mb-0.5 text-center text-[10px] text-muted-foreground">{t('landing.experience.enterprise.serviceLayer')}</div>
|
||||
<div className="rounded-lg bg-neutral-900 px-3.5 py-3 text-center text-xs font-semibold leading-tight text-white">SkillHub<br />Registry</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<svg className="h-4 w-16 text-border" fill="none" stroke="currentColor" viewBox="0 0 64 16"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" strokeDasharray="3 3" d="M0 8h54" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M50 4l6 4-6 4" /></svg>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink-0">
|
||||
<div className="mb-0.5 text-center text-[10px] text-muted-foreground">{t('landing.experience.enterprise.storageLayer')}</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">PostgreSQL</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">Redis</div>
|
||||
<div className="rounded-md border border-border/70 bg-background px-3 py-1.5 text-center text-[11px] font-medium text-muted-foreground">MinIO / S3</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-dashed border-border/70 pt-2.5 text-center text-[10px] text-muted-foreground">{t('landing.experience.enterprise.networkBoundary')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('landing.experience.enterprise.security.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('landing.experience.enterprise.security.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Lock className="h-5 w-5 text-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('landing.experience.enterprise.rbac.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('landing.experience.enterprise.rbac.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Settings className="h-5 w-5 text-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('landing.experience.enterprise.access.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('landing.experience.enterprise.access.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<Monitor className="h-5 w-5 text-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('landing.experience.enterprise.audit.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('landing.experience.enterprise.audit.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-[var(--shadow-card)]">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<GitBranch className="h-5 w-5 text-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('landing.experience.enterprise.openSource.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('landing.experience.enterprise.openSource.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marketing-style landing page for unauthenticated and first-time visitors.
|
||||
*
|
||||
|
|
@ -34,9 +273,9 @@ export function LandingPage() {
|
|||
}
|
||||
|
||||
const heroView = useInView()
|
||||
const statsView = useInView()
|
||||
const featuresView = useInView()
|
||||
const quickStartView = useInView()
|
||||
const enterpriseView = useInView()
|
||||
const popularView = useInView()
|
||||
const latestView = useInView()
|
||||
|
||||
|
|
@ -50,187 +289,152 @@ export function LandingPage() {
|
|||
|
||||
const features = [
|
||||
{
|
||||
icon: <Shield className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
icon: <Shield className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.secure.title'),
|
||||
description: t('landing.features.secure.description'),
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
icon: <Users className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.community.title'),
|
||||
description: t('landing.features.community.description'),
|
||||
},
|
||||
{
|
||||
icon: <PackageOpen className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
icon: <PackageOpen className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.integration.title'),
|
||||
description: t('landing.features.integration.description'),
|
||||
},
|
||||
{
|
||||
icon: <GitBranch className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
title: t('landing.features.versionControl.title', { defaultValue: 'Version control' }),
|
||||
description: t('landing.features.versionControl.description', { defaultValue: 'Managed release flows keep skill packages traceable and easier to review.' }),
|
||||
icon: <GitBranch className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.versionControl.title'),
|
||||
description: t('landing.features.versionControl.description'),
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
title: t('landing.features.cli.title', { defaultValue: 'CLI tooling' }),
|
||||
description: t('landing.features.cli.description', { defaultValue: 'Command-line workflows support publishing, installing, and operating skills quickly.' }),
|
||||
icon: <Terminal className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.cli.title'),
|
||||
description: t('landing.features.cli.description'),
|
||||
},
|
||||
{
|
||||
icon: <Settings className="w-6 h-6 text-white" strokeWidth={2} />,
|
||||
title: t('landing.features.governance.title', { defaultValue: 'Governance' }),
|
||||
description: t('landing.features.governance.description', { defaultValue: 'Built-in review and permission flows help teams enforce skill quality.' }),
|
||||
icon: <Settings className="h-5 w-5" strokeWidth={1.75} />,
|
||||
title: t('landing.features.governance.title'),
|
||||
description: t('landing.features.governance.description'),
|
||||
},
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ value: '1000+', label: t('landing.stats.skills', { defaultValue: 'Registry items' }) },
|
||||
{ value: '50K+', label: t('landing.stats.downloads', { defaultValue: 'Downloads' }) },
|
||||
{ value: '200+', label: t('landing.stats.teams', { defaultValue: 'Teams' }) },
|
||||
const heroHighlights = [
|
||||
{ icon: <PackageOpen className="h-4 w-4" strokeWidth={1.5} />, label: t('landing.experience.highlights.selfHosted') },
|
||||
{ icon: <Shield className="h-4 w-4" strokeWidth={1.5} />, label: t('landing.experience.highlights.governance') },
|
||||
{ icon: <Terminal className="h-4 w-4" strokeWidth={1.5} />, label: t('landing.experience.highlights.multiClient') },
|
||||
{ icon: <Clock3 className="h-4 w-4" strokeWidth={1.5} />, label: t('landing.experience.highlights.traceable') },
|
||||
]
|
||||
|
||||
const popularTitle = t('home.popularTitle')
|
||||
const popularDescription = t('home.popularDescription')
|
||||
const latestTitle = t('home.latestTitle')
|
||||
const latestDescription = t('home.latestDescription')
|
||||
const viewAllText = t('home.viewAll')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<main ref={heroView.ref} className={`relative z-10 flex flex-col items-center pt-16 pb-20 px-4 md:pt-24 scroll-fade-up${heroView.inView ? ' in-view' : ''}`}>
|
||||
<h1 className="text-5xl md:text-7xl font-bold tracking-tight text-brand-gradient mb-4">
|
||||
SkillHub
|
||||
</h1>
|
||||
<h2
|
||||
className="text-xl md:text-2xl font-semibold tracking-tight text-center mb-3"
|
||||
style={{ color: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
{t('landing.hero.title')}
|
||||
</h2>
|
||||
<p
|
||||
className="text-base md:text-lg text-center max-w-2xl mb-10 leading-relaxed"
|
||||
style={{ color: 'hsl(var(--text-secondary))' }}
|
||||
>
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Search box */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div
|
||||
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('landing.hero.searchPlaceholder')}
|
||||
className="hero-input flex-1 bg-transparent outline-none text-base"
|
||||
style={{ color: 'hsl(var(--foreground))' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch((e.target as HTMLInputElement).value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<main ref={heroView.ref} className={`relative z-10 w-full overflow-hidden px-6 py-16 scroll-fade-up${heroView.inView ? ' in-view' : ''} md:py-20`}>
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute left-1/2 top-0 h-[400px] w-[900px] -translate-x-1/2 rounded-full bg-secondary blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-14">
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="px-8 py-3.5 rounded-xl text-base font-medium text-white bg-brand-gradient shadow-sm hover:opacity-95 transition-opacity"
|
||||
>
|
||||
{t('landing.hero.exploreSkills')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/publish"
|
||||
className="px-8 py-3.5 rounded-xl text-base font-medium border transition-colors"
|
||||
style={{
|
||||
background: 'hsl(var(--secondary))',
|
||||
borderColor: 'hsl(var(--muted-foreground))',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
}}
|
||||
>
|
||||
{t('landing.hero.publishSkill', { defaultValue: '开始构建' })}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div ref={statsView.ref} className={`flex flex-row justify-center gap-16 md:gap-24 scroll-fade-up${statsView.inView ? ' in-view' : ''}`} style={{ transitionDelay: '0.15s' }}>
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex flex-col items-center">
|
||||
<span className="text-3xl md:text-4xl font-bold tracking-tight text-brand-gradient mb-1">
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-sm font-normal" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{stat.label}
|
||||
</span>
|
||||
<div className="mx-auto grid max-w-6xl grid-cols-1 items-start gap-12 lg:grid-cols-2 lg:items-center lg:gap-16">
|
||||
<div>
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border/70 bg-secondary/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
{t('landing.experience.heroBadge')}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h1 className="mb-5 max-w-2xl text-4xl font-medium leading-[1.12] tracking-tight text-foreground md:text-5xl">
|
||||
{t('landing.experience.heroTitle')}
|
||||
</h1>
|
||||
|
||||
<p className="mb-8 max-w-xl text-lg leading-relaxed text-muted-foreground">
|
||||
{t('landing.experience.heroDescription')}
|
||||
</p>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-3">
|
||||
<Link to="/dashboard/publish" className="btn-pill btn-pill-primary">
|
||||
{t('landing.hero.publishSkill')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
search={{ q: '', sort: 'relevance', page: 0, starredOnly: false }}
|
||||
className="group btn-pill btn-pill-outline inline-flex items-center gap-2"
|
||||
>
|
||||
{t('landing.hero.exploreSkills')} <ArrowRight className="h-4 w-4 transition-transform duration-150 group-hover:translate-x-0.5 motion-reduce:transform-none" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||
{heroHighlights.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroBrowserMockup onSearch={handleSearch} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Features Section */}
|
||||
<section ref={featuresView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${featuresView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-14">
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-3" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('landing.whySkillHub.title', { defaultValue: '为什么选择 SkillHub' })}
|
||||
<div className="mx-auto max-w-6xl px-6"><div className="h-px bg-border/70" /></div>
|
||||
|
||||
<section ref={featuresView.ref} className={`relative z-10 w-full bg-background px-6 py-16 scroll-fade-up${featuresView.inView ? ' in-view' : ''} md:py-20`}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-12 max-w-3xl">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('landing.experience.capabilities.eyebrow')}</p>
|
||||
<h2 className="mb-4 text-3xl font-medium tracking-tight text-foreground md:text-4xl">
|
||||
{t('landing.experience.capabilities.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('landing.whySkillHub.subtitle', { defaultValue: '专为企业打造的私有化 Agent 技能管理平台' })}
|
||||
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">
|
||||
{t('landing.experience.capabilities.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-2xl border border-border/70 bg-border/70 md:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">
|
||||
<div key={feature.title} className="bg-card p-7 transition-colors hover:bg-secondary/50">
|
||||
<div className="mb-5 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-foreground">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-center mb-3" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-center leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{feature.description}
|
||||
</p>
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">{feature.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div ref={quickStartView.ref} className={`scroll-fade-up${quickStartView.inView ? ' in-view' : ''}`}>
|
||||
<LandingQuickStartSection />
|
||||
</div>
|
||||
|
||||
{/* Popular Downloads Section */}
|
||||
<section ref={popularView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${popularView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<section ref={popularView.ref} className={`relative z-10 w-full bg-background px-6 py-16 scroll-fade-up${popularView.inView ? ' in-view' : ''} md:py-20`}>
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-2" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('home.popularTitle')}
|
||||
</h2>
|
||||
<p style={{ color: 'hsl(var(--text-secondary))' }}>{t('home.popularDescription')}</p>
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('landing.experience.marketplace')}</p>
|
||||
<h2 className="mb-2 text-3xl font-medium tracking-tight text-foreground">{popularTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">{popularDescription}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'downloads', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('home.viewAll')}
|
||||
{viewAllText}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingPopular ? (
|
||||
<SkeletonList count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{popularSkills?.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
<SkillCard skill={skill} onClick={() => handleSkillClick(skill.namespace, skill.slug)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -238,39 +442,53 @@ export function LandingPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Latest Releases Section */}
|
||||
<section ref={latestView.ref} className={`relative z-10 w-full py-20 md:py-24 px-6 scroll-fade-up${latestView.inView ? ' in-view' : ''}`} style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<section ref={latestView.ref} className={`relative z-10 w-full border-y border-border/70 bg-secondary/70 px-6 py-16 scroll-fade-up${latestView.inView ? ' in-view' : ''} md:py-20`}>
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight mb-2" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('home.latestTitle')}
|
||||
</h2>
|
||||
<p style={{ color: 'hsl(var(--text-secondary))' }}>{t('home.latestDescription')}</p>
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('landing.experience.latest')}</p>
|
||||
<h2 className="mb-2 text-3xl font-medium tracking-tight text-foreground">{latestTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">{latestDescription}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate({ to: '/search', search: { q: '', sort: 'newest', page: 0, starredOnly: false } })}
|
||||
>
|
||||
{t('home.viewAll')}
|
||||
{viewAllText}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoadingLatest ? (
|
||||
<SkeletonList count={6} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{latestSkills?.items.map((skill, idx) => (
|
||||
<div key={skill.id} className={`animate-fade-up delay-${Math.min(idx + 1, 6)}`}>
|
||||
<SkillCard
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill.namespace, skill.slug)}
|
||||
/>
|
||||
<SkillCard skill={skill} onClick={() => handleSkillClick(skill.namespace, skill.slug)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div ref={enterpriseView.ref} className={`scroll-fade-up${enterpriseView.inView ? ' in-view' : ''}`}>
|
||||
<EnterpriseSection />
|
||||
</div>
|
||||
|
||||
<section className="relative z-10 w-full border-t border-border/70 bg-background px-6 py-16 text-center md:py-20">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="mb-3 text-2xl font-medium tracking-tight text-foreground md:text-3xl">{t('landing.experience.cta.title')}</h2>
|
||||
<p className="mb-8 text-muted-foreground">{t('landing.experience.cta.description')}</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="https://github.com/iflytek/skillhub" target="_blank" rel="noreferrer" className="btn-pill btn-pill-primary">
|
||||
{t('landing.experience.cta.deploy')}
|
||||
</a>
|
||||
<a href="https://iflytek.github.io/skillhub/" target="_blank" rel="noreferrer" className="group btn-pill btn-pill-outline inline-flex items-center gap-2">
|
||||
{t('landing.experience.cta.docs')} <CheckCircle2 className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ describe('SearchPage', () => {
|
|||
expect(html).toContain('flex flex-wrap items-center gap-2')
|
||||
})
|
||||
|
||||
it('wraps sort controls within narrow viewports', () => {
|
||||
const html = renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(html).toContain('flex min-w-0 flex-wrap items-center gap-3')
|
||||
expect(html).toContain('flex max-w-full flex-wrap gap-2')
|
||||
})
|
||||
|
||||
it('toggles the selected label off and resets paging', () => {
|
||||
renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
|
|
|
|||
|
|
@ -235,9 +235,9 @@ export function SearchPage() {
|
|||
{/* Sort And Filters */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t('search.sort.label')}</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex max-w-full flex-wrap gap-2">
|
||||
<Button
|
||||
variant={sort === 'relevance' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { NotificationPreferenceForm } from '@/features/notification/notification-preference-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
|
||||
/**
|
||||
* Settings page for managing notification preferences at /settings/notifications.
|
||||
*/
|
||||
export function NotificationSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<NotificationPreferenceForm />
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('notification.preferences.title')} subtitle={t('notification.preferences.description')} />
|
||||
<NotificationPreferenceForm showHeader={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ vi.mock('@/api/client', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ user: { displayName: 'Test', avatarUrl: null, email: 'test@test.com' } }),
|
||||
useAuth: () => ({ user: { displayName: 'Test', avatarUrl: null, email: 'test@test.com', canChangePassword: true } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import { useAuth } from '@/features/auth/use-auth'
|
|||
import { truncateErrorMessage } from '@/shared/lib/error-display'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
|
||||
/** Regex matching allowed display name characters: Chinese, English, digits, spaces, underscore, hyphen. */
|
||||
const DISPLAY_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z0-9_ -]+$/
|
||||
|
|
@ -175,18 +176,17 @@ export function ProfileSettingsPage() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
<Card className="glass-strong">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('profile.title')}</CardTitle>
|
||||
<CardDescription>{t('profile.subtitle')}</CardDescription>
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-end">
|
||||
{!isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void navigate({ to: '/reset-password' })}>
|
||||
{t('profile.resetPassword')}
|
||||
</Button>
|
||||
{user?.canChangePassword === true ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void navigate({ to: '/settings/security' })}>
|
||||
{t('profile.resetPassword')}
|
||||
</Button>
|
||||
) : null}
|
||||
{hasEditableFields ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleEdit}>
|
||||
{t('profile.edit')}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import { clearSessionScopedQueries } from '@/features/notification/notification-
|
|||
import { truncateErrorMessage } from '@/shared/lib/error-display'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Card, CardContent } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
|
||||
interface PasswordChangeCapabilityUser {
|
||||
canChangePassword?: boolean
|
||||
|
|
@ -87,12 +88,9 @@ export function SecuritySettingsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('security.title')} subtitle={t('security.subtitle')} />
|
||||
<Card className="glass-strong">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('security.title')}</CardTitle>
|
||||
<CardDescription>{t('security.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{canChangePassword ? (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
|
|
|
|||
23
web/src/shared/components/brand-mark.tsx
Normal file
23
web/src/shared/components/brand-mark.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { withBasePath } from '@/shared/lib/base-path'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface BrandMarkProps {
|
||||
className?: string
|
||||
imageClassName?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一展示 SkillHub 项目头像,复用 public/favicon.svg 避免首页、页脚重复绘制字母占位图标。
|
||||
*/
|
||||
export function BrandMark({ className, imageClassName, alt = 'SkillHub' }: BrandMarkProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center justify-center overflow-hidden rounded-xl', className)}>
|
||||
<img
|
||||
src={withBasePath('/favicon.svg')}
|
||||
alt={alt}
|
||||
className={cn('h-full w-full object-contain', imageClassName)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
interface DashboardPageHeaderProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
|
|
@ -11,21 +6,17 @@ interface DashboardPageHeaderProps {
|
|||
|
||||
/**
|
||||
* Standard header used by dashboard sub-pages so navigation and page framing stay consistent.
|
||||
*
|
||||
* The "back to dashboard" link is intentionally omitted — the sidebar already provides
|
||||
* complete navigation and makes a dedicated back link redundant.
|
||||
*/
|
||||
export function DashboardPageHeader({ title, subtitle, actions }: DashboardPageHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" className="px-0 text-muted-foreground hover:text-foreground" onClick={() => navigate({ to: '/dashboard' })}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('dashboard.backToDashboard')}
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold font-heading mb-2">{title}</h1>
|
||||
{subtitle ? <p className="text-muted-foreground text-lg">{subtitle}</p> : null}
|
||||
<h1 className="text-2xl font-bold tracking-tight" style={{ color: 'hsl(var(--foreground))' }}>{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm" style={{ color: 'hsl(var(--text-secondary))' }}>{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as mod from './landing-quick-start'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string>) => values?.url
|
||||
? `${key} ${values.url}`
|
||||
: key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* LandingQuickStartSection is a React component that renders a tabbed quick-start
|
||||
* section with agent/human tabs and copy-to-clipboard commands.
|
||||
|
|
@ -11,7 +27,49 @@ import * as mod from './landing-quick-start'
|
|||
* if the export contract changes.
|
||||
*/
|
||||
describe('landing-quick-start module exports', () => {
|
||||
const originalRuntimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = originalRuntimeConfig
|
||||
})
|
||||
|
||||
it('exports the LandingQuickStartSection component', () => {
|
||||
expect(mod.LandingQuickStartSection).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('uses the self-hosted Registry URL in displayed and copied CLI commands', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||
appBaseUrl: 'https://registry.internal.example/skills',
|
||||
}
|
||||
|
||||
render(createElement(mod.LandingQuickStartSection))
|
||||
const cliLabel = screen.getByText('landing.experience.quickStart.modes.cli.title')
|
||||
await act(async () => fireEvent.click(cliLabel.closest('button')!))
|
||||
|
||||
expect(screen.getAllByText('https://registry.internal.example/skills')).toHaveLength(2)
|
||||
expect(screen.getByText('registry: https://registry.internal.example/skills')).toBeTruthy()
|
||||
|
||||
const copyButtons = screen.getAllByRole('button', {
|
||||
name: 'landing.experience.quickStart.copy',
|
||||
})
|
||||
await act(async () => fireEvent.click(copyButtons[1]))
|
||||
await act(async () => fireEvent.click(copyButtons[2]))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npx -y @astron-team/skillhub@0.1.12 search weather --registry https://registry.internal.example/skills --limit 5',
|
||||
)
|
||||
expect(writeText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'npx -y @astron-team/skillhub@0.1.12 install @global/weather --dir ./skills --registry https://registry.internal.example/skills',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,171 +1,396 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Bot, Check, Copy, Terminal, UserRound } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useCopyToClipboard } from '@/shared/lib/clipboard'
|
||||
import { Check, ChevronRight, Copy, Download, FileText, Search } from 'lucide-react'
|
||||
import { buildApiUrl, WEB_API_PREFIX } from '@/api/client'
|
||||
import { copyToClipboard, useCopyToClipboard } from '@/shared/lib/clipboard'
|
||||
import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
type LandingQuickStartTabId = 'agent' | 'human' | 'cli'
|
||||
type AccessMode = 'agent' | 'cli' | 'web'
|
||||
type AgentView = 'registry' | 'discovery'
|
||||
|
||||
interface LandingQuickStartTab {
|
||||
id: LandingQuickStartTabId
|
||||
label: string
|
||||
description: string
|
||||
command: string
|
||||
interface AccessModeOption {
|
||||
id: AccessMode
|
||||
number: string
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
const tabIcons: Record<LandingQuickStartTabId, LucideIcon> = {
|
||||
agent: Bot,
|
||||
human: UserRound,
|
||||
cli: Terminal,
|
||||
}
|
||||
const ACCESS_MODES: AccessModeOption[] = [
|
||||
{ id: 'agent', number: '01', titleKey: 'agent.title', descriptionKey: 'agent.description' },
|
||||
{ id: 'cli', number: '02', titleKey: 'cli.title', descriptionKey: 'cli.description' },
|
||||
{ id: 'web', number: '03', titleKey: 'web.title', descriptionKey: 'web.description' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the base URL for the application.
|
||||
* Prefers the runtime config if set and not localhost.
|
||||
* Falls back to the current page origin.
|
||||
*/
|
||||
function getAppBaseUrl(): string {
|
||||
function getRegistryUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return ''
|
||||
return 'https://skill.xfyun.cn'
|
||||
}
|
||||
const runtimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__
|
||||
|
||||
return resolvePublicRegistryUrl(
|
||||
runtimeConfig?.appBaseUrl,
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__?.appBaseUrl,
|
||||
`${window.location.protocol}//${window.location.host}`,
|
||||
)
|
||||
}
|
||||
|
||||
function CompactCopyButton({ text }: { text: string }) {
|
||||
function AgentAccessPanel() {
|
||||
const { t } = useTranslation()
|
||||
const [activeView, setActiveView] = useState<AgentView>('registry')
|
||||
const [copied, copy] = useCopyToClipboard()
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copy(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const label = copied ? (t('copyButton.copied') || 'Copied') : (t('copyButton.copy') || 'Copy')
|
||||
const registryUrl = useMemo(getRegistryUrl, [])
|
||||
const instruction = t('landing.experience.quickStart.agent.instruction', { url: `${registryUrl}/registry/skill.md` })
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="min-h-[340px]">
|
||||
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="inline-flex w-fit items-center gap-1 border-b border-border/70" role="tablist" aria-label={t('landing.experience.quickStart.agent.tablist')}>
|
||||
{([
|
||||
['registry', t('landing.experience.quickStart.agent.registryTab')],
|
||||
['discovery', t('landing.experience.quickStart.agent.discoveryTab')],
|
||||
] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeView === id}
|
||||
onClick={() => setActiveView(id)}
|
||||
className={`relative px-4 py-2.5 text-xs font-semibold transition-[color,background-color] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${activeView === id ? 'bg-background/70 text-foreground' : 'text-muted-foreground hover:bg-background/40 hover:text-foreground'}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`absolute inset-x-0 -bottom-px h-0.5 origin-center bg-foreground transition-transform duration-200 ${activeView === id ? 'scale-x-100' : 'scale-x-0'}`} aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
{t('landing.experience.quickStart.agent.live')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeView === 'registry' ? (
|
||||
<div className="animate-fade-up py-9">
|
||||
<p className="mb-5 text-xs text-muted-foreground">{t('landing.experience.quickStart.agent.instructionLead')}</p>
|
||||
<div className="flex flex-col gap-5 border-y border-border/70 py-5 sm:flex-row sm:items-start">
|
||||
<span className="mt-1 hidden h-12 w-1 flex-shrink-0 bg-foreground sm:block" aria-hidden />
|
||||
<p className="min-w-0 flex-1 text-sm leading-7 text-foreground">
|
||||
{t('landing.experience.quickStart.agent.instructionPrefix')} <span className="break-all text-blue-600 underline decoration-blue-300 underline-offset-4">{registryUrl}/registry/skill.md</span> {t('landing.experience.quickStart.agent.instructionSuffix')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void copy(instruction).catch(() => {
|
||||
toast.error(t('landing.experience.quickStart.copyErrorTitle'), t('landing.experience.quickStart.agent.copyErrorDescription'))
|
||||
})
|
||||
}}
|
||||
className="inline-flex w-fit flex-shrink-0 items-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-xs font-semibold text-background shadow-sm transition-[transform,opacity,box-shadow] duration-150 hover:-translate-y-px hover:opacity-90 hover:shadow-md active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? t('landing.experience.quickStart.copied') : t('landing.experience.quickStart.agent.copyInstruction')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{[
|
||||
t('landing.experience.quickStart.agent.steps.read'),
|
||||
t('landing.experience.quickStart.agent.steps.configure'),
|
||||
t('landing.experience.quickStart.agent.steps.discover'),
|
||||
].map((step, index) => (
|
||||
<div key={step} className="flex items-center gap-2">
|
||||
{index > 0 ? <span className="h-px w-6 bg-border" /> : null}
|
||||
<span>{step}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-up py-8">
|
||||
<div className="mb-7 flex justify-end">
|
||||
<span className="border-b border-foreground pb-2 text-sm text-foreground">{t('landing.experience.quickStart.agent.prompt')}</span>
|
||||
</div>
|
||||
<div className="relative space-y-0 pl-7 before:absolute before:bottom-3 before:left-[5px] before:top-3 before:w-px before:bg-border">
|
||||
{[
|
||||
[t('landing.experience.quickStart.agent.discovery.intentTitle'), t('landing.experience.quickStart.agent.discovery.intentDescription')],
|
||||
[t('landing.experience.quickStart.agent.discovery.searchTitle'), t('landing.experience.quickStart.agent.discovery.searchDescription')],
|
||||
[t('landing.experience.quickStart.agent.discovery.readTitle'), t('landing.experience.quickStart.agent.discovery.readDescription')],
|
||||
].map(([title, description], index) => (
|
||||
<div key={title} className={`relative grid grid-cols-1 gap-1 border-b border-border/60 py-3.5 sm:grid-cols-[9rem_1fr] animate-fade-up delay-${index + 1}`}>
|
||||
<span className="absolute -left-[26px] top-[20px] h-2.5 w-2.5 rounded-full border-2 border-background bg-muted-foreground ring-1 ring-border" />
|
||||
<strong className="text-xs font-semibold text-foreground">{title}</strong>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-6 border-t-2 border-foreground pt-5 text-sm leading-7 text-foreground">
|
||||
<strong className="text-emerald-700 dark:text-emerald-400">{t('landing.experience.quickStart.agent.answerLead')}</strong>{t('landing.experience.quickStart.agent.answerTail')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CliAccessPanel() {
|
||||
const { t } = useTranslation()
|
||||
const [copiedIdx, setCopiedIdx] = useState(-1)
|
||||
const registryUrl = useMemo(getRegistryUrl, [])
|
||||
|
||||
const handleCopy = (text: string, idx: number) => {
|
||||
void copyToClipboard(text)
|
||||
.then(() => {
|
||||
setCopiedIdx(idx)
|
||||
window.setTimeout(() => setCopiedIdx((prev) => (prev === idx ? -1 : prev)), 2000)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('landing.experience.quickStart.copyErrorTitle'), t('landing.experience.quickStart.cli.copyErrorDescription'))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[380px] flex-col overflow-hidden rounded-2xl border border-border/70 shadow-sm">
|
||||
{/* Terminal title bar */}
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-white px-4 py-3 dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
|
||||
</div>
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">Terminal — skillhub</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">zsh</span>
|
||||
<span className="text-[10px] text-border">·</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">80×24</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
<div className="flex-1 overflow-auto bg-[#fafafa] p-4 font-mono text-[12px] leading-[1.7] dark:bg-neutral-950">
|
||||
{/* Version check */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
|
||||
<div className="flex-1">
|
||||
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.12 --version</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy('npx -y @astron-team/skillhub@0.1.12 --version', 0)}
|
||||
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
|
||||
>
|
||||
{copiedIdx === 0 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copiedIdx === 0 ? t('landing.experience.quickStart.copied') : t('landing.experience.quickStart.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5 text-muted-foreground">SkillHub CLI 0.1.12</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
|
||||
<div className="flex-1">
|
||||
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.12 search weather \</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(`npx -y @astron-team/skillhub@0.1.12 search weather --registry ${registryUrl} --limit 5`, 1)}
|
||||
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
|
||||
>
|
||||
{copiedIdx === 1 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copiedIdx === 1 ? t('landing.experience.quickStart.copied') : t('landing.experience.quickStart.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
|
||||
<span className="text-muted-foreground">{' --registry'} </span>
|
||||
<span className="break-all text-blue-600 dark:text-blue-400">{registryUrl}</span> \
|
||||
</div>
|
||||
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
|
||||
<span className="text-muted-foreground">{' --limit'} </span>
|
||||
<span className="text-neutral-800 dark:text-neutral-200">5</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 pl-5">
|
||||
<div className="text-[11px] text-muted-foreground">{t('landing.experience.quickStart.cli.skillsFound')}</div>
|
||||
<div className="flex gap-4">
|
||||
<span className="text-blue-600 dark:text-blue-400">@global/weather</span>
|
||||
<span className="text-muted-foreground">v1.3.0</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('landing.experience.demoSkills.weather')}</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<span className="text-blue-600 dark:text-blue-400">@global/forecast</span>
|
||||
<span className="text-muted-foreground">v2.1.0</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('landing.experience.quickStart.cli.forecastSummary')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
|
||||
<div className="flex-1">
|
||||
<span className="text-neutral-800 dark:text-neutral-200">npx -y @astron-team/skillhub@0.1.12 install @global/weather \</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(`npx -y @astron-team/skillhub@0.1.12 install @global/weather --dir ./skills --registry ${registryUrl}`, 2)}
|
||||
className="ml-2 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] text-muted-foreground align-middle transition-[color,background-color,transform] hover:bg-neutral-100 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-neutral-800"
|
||||
>
|
||||
{copiedIdx === 2 ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copiedIdx === 2 ? t('landing.experience.quickStart.copied') : t('landing.experience.quickStart.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
|
||||
<span className="text-muted-foreground">{' --dir'} </span>
|
||||
<span className="text-neutral-800 dark:text-neutral-200">./skills</span> \
|
||||
</div>
|
||||
<div className="pl-5 text-neutral-600 dark:text-neutral-400">
|
||||
<span className="text-muted-foreground">{' --registry'} </span>
|
||||
<span className="break-all text-blue-600 dark:text-blue-400">{registryUrl}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 pl-5 text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
<span>{t('landing.experience.quickStart.cli.installed')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="select-none font-bold text-emerald-600 dark:text-emerald-400">$</span>
|
||||
<span className="inline-block h-3.5 w-2 animate-pulse bg-neutral-700 dark:bg-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom status bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-white px-4 py-2.5 text-[11px] dark:bg-neutral-900">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t('landing.experience.quickStart.cli.connected')}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span className="break-all font-mono">registry: {registryUrl}</span>
|
||||
</div>
|
||||
<a href="https://github.com/iflytek/skillhub/tree/main/cli" target="_blank" rel="noreferrer" className="group flex items-center gap-1 font-medium text-blue-600 transition-colors hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{t('landing.experience.quickStart.cli.docs')}
|
||||
<ChevronRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5 motion-reduce:transform-none" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebAccessPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="min-h-[340px]">
|
||||
<div className="grid gap-6 md:grid-cols-[11rem_minmax(0,1fr)]">
|
||||
<div className="border-b border-border/70 pb-5 md:border-b-0 md:border-r md:pb-0 md:pr-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<strong className="text-xs font-semibold text-foreground">{t('landing.experience.quickStart.web.marketplace')}</strong>
|
||||
<span className="text-[10px] text-muted-foreground">{t('landing.experience.quickStart.web.publicSkills')}</span>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-2 border-b border-border/70 pb-2.5 text-[11px] text-muted-foreground">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
{t('landing.experience.quickStart.web.search')}
|
||||
</div>
|
||||
{[
|
||||
['W', 'weather', '@global'],
|
||||
['G', 'git-helper', '@devtools'],
|
||||
['D', 'diagram-maker', '@global'],
|
||||
].map(([letter, name, namespace], index) => (
|
||||
<div key={name} className={`flex items-center gap-2.5 border-b border-border/50 py-3 ${index === 0 ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
<span className={`flex h-7 w-7 items-center justify-center rounded-md text-[10px] font-bold ${index === 0 ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300' : 'bg-secondary'}`}>{letter}</span>
|
||||
<span className="min-w-0">
|
||||
<strong className="block truncate text-[11px] font-semibold">{name}</strong>
|
||||
<span className="font-mono text-[9px]">{namespace}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />{t('landing.experience.quickStart.web.status')}
|
||||
</span>
|
||||
<h3 className="mt-2 text-xl font-semibold tracking-tight text-foreground">weather <span className="font-mono text-xs font-normal text-muted-foreground">@global</span></h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('landing.experience.quickStart.web.summary')}</p>
|
||||
</div>
|
||||
<a href={buildApiUrl(`${WEB_API_PREFIX}/skills/global/weather/download`)} className="inline-flex w-fit items-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-xs font-semibold text-background shadow-sm transition-[transform,opacity,box-shadow] duration-150 hover:-translate-y-px hover:opacity-90 hover:shadow-md active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transform-none">
|
||||
<Download className="h-3.5 w-3.5" />{t('landing.experience.quickStart.web.download')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 border-t border-border/70 pt-5 sm:grid-cols-[minmax(0,1fr)_8rem]">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{t('landing.experience.quickStart.web.readTitle')}
|
||||
</div>
|
||||
<p className="text-[11px] leading-6 text-muted-foreground">
|
||||
{t('landing.experience.quickStart.web.readDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="space-y-3 text-[10px]">
|
||||
<div className="flex justify-between border-b border-border/60 pb-2"><dt className="text-muted-foreground">{t('landing.experience.quickStart.web.version')}</dt><dd className="font-mono text-foreground">v1.3.0</dd></div>
|
||||
<div className="flex justify-between border-b border-border/60 pb-2"><dt className="text-muted-foreground">{t('landing.experience.quickStart.web.files')}</dt><dd className="text-foreground">3</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-muted-foreground">{t('landing.experience.quickStart.web.format')}</dt><dd className="text-foreground">ZIP</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[11px] text-muted-foreground">
|
||||
<span>{t('landing.experience.quickStart.web.browse')}</span><span>{t('landing.experience.quickStart.web.preview')}</span><span>{t('landing.experience.quickStart.web.versions')}</span><span>{t('landing.experience.quickStart.web.favorites')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LandingQuickStartSection() {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState<LandingQuickStartTabId>('agent')
|
||||
const baseUrl = useMemo(() => getAppBaseUrl(), [])
|
||||
|
||||
// Build dynamic agent command with actual registry URL
|
||||
const agentCommand = t('landing.quickStart.agent.commandTemplate', {
|
||||
defaultValue: t('landing.quickStart.agent.command'),
|
||||
url: `${baseUrl}/registry/skill.md`,
|
||||
})
|
||||
const humanCommand = t('landing.quickStart.human.commandTemplate', {
|
||||
defaultValue: t('landing.quickStart.human.command'),
|
||||
url: baseUrl,
|
||||
})
|
||||
|
||||
const tabs: LandingQuickStartTab[] = [
|
||||
{
|
||||
id: 'agent',
|
||||
label: t('landing.quickStart.tabs.agent'),
|
||||
description: t('landing.quickStart.agent.description'),
|
||||
command: agentCommand,
|
||||
},
|
||||
{
|
||||
id: 'human',
|
||||
label: t('landing.quickStart.tabs.human'),
|
||||
description: t('landing.quickStart.human.description'),
|
||||
command: humanCommand,
|
||||
},
|
||||
{
|
||||
id: 'cli',
|
||||
label: t('landing.quickStart.tabs.cli'),
|
||||
description: t('landing.quickStart.cli.description'),
|
||||
command: t('landing.quickStart.cli.command'),
|
||||
},
|
||||
]
|
||||
|
||||
const currentTab = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]
|
||||
const [activeMode, setActiveMode] = useState<AccessMode>('agent')
|
||||
|
||||
return (
|
||||
<section className="relative z-10 w-full px-6 py-14 md:py-16" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-7 md:mb-8">
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-3" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
{t('landing.quickStart.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{t('landing.quickStart.description', { defaultValue: t('landing.quickStart.subtitle') })}
|
||||
</p>
|
||||
<section id="quickstart" className="relative z-10 w-full overflow-hidden bg-secondary/70 px-6 py-16 md:py-20">
|
||||
<div className="absolute inset-0 bg-dots opacity-40" aria-hidden />
|
||||
<div className="relative mx-auto max-w-6xl">
|
||||
<div className="mb-12 max-w-3xl">
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('landing.quickStart.title')}</p>
|
||||
<h2 className="mb-4 text-3xl font-medium tracking-tight text-foreground md:text-4xl">{t('landing.experience.quickStart.title')}</h2>
|
||||
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">{t('landing.experience.quickStart.description')}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab.id
|
||||
const Icon = tabIcons[tab.id]
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-12">
|
||||
<div className="border-t border-border/70">
|
||||
{ACCESS_MODES.map((mode) => {
|
||||
const active = activeMode === mode.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
aria-pressed={isActive}
|
||||
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? 'hsl(var(--card))' : 'transparent',
|
||||
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
|
||||
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
|
||||
}}
|
||||
aria-pressed={active}
|
||||
onClick={() => setActiveMode(mode.id)}
|
||||
className={`group relative flex w-full items-center gap-4 border-b border-border/70 px-2 py-5 text-left transition-[background-color,transform] duration-200 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset ${active ? 'bg-background/65' : 'hover:bg-background/35 active:translate-x-px'}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.75} />
|
||||
<span>{tab.label}</span>
|
||||
<span className={`absolute inset-y-3 left-0 w-0.5 origin-center bg-foreground transition-transform duration-200 ${active ? 'scale-y-100' : 'scale-y-0'}`} aria-hidden />
|
||||
<span className={`font-mono text-[11px] font-semibold transition-colors duration-200 ${active ? 'text-foreground' : 'text-muted-foreground'}`}>{mode.number}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<strong className={`block text-sm font-semibold transition-colors ${active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground'}`}>{t(`landing.experience.quickStart.modes.${mode.titleKey}`)}</strong>
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">{t(`landing.experience.quickStart.modes.${mode.descriptionKey}`)}</span>
|
||||
</span>
|
||||
<span className={`h-1.5 w-1.5 rounded-full transition-colors ${active ? 'bg-foreground' : 'bg-border'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 pt-8 md:px-8 md:pb-6 md:pt-9">
|
||||
<p
|
||||
className="mx-auto mb-6 max-w-xl text-center text-base font-medium leading-relaxed md:text-lg"
|
||||
style={{ color: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
{currentTab.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<div className="overflow-x-auto whitespace-nowrap">
|
||||
<code
|
||||
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
|
||||
>
|
||||
{currentTab.command}
|
||||
</code>
|
||||
</div>
|
||||
<CompactCopyButton text={currentTab.command} />
|
||||
</div>
|
||||
<div key={activeMode} className="animate-fade-up border-t border-border/70 pt-5">
|
||||
{activeMode === 'agent' ? <AgentAccessPanel /> : null}
|
||||
{activeMode === 'cli' ? <CliAccessPanel /> : null}
|
||||
{activeMode === 'web' ? <WebAccessPanel /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,18 +61,18 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
size="sm"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
|
||||
className={cn('cursor-pointer gap-1.5 text-muted-foreground hover:text-foreground transition-colors', className)}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
|
||||
<span className="hidden text-sm font-medium text-inherit sm:inline">{currentLanguage.name}</span>
|
||||
<ChevronDown className={cn('hidden h-3.5 w-3.5 transition-transform duration-200 sm:block', open && 'rotate-180')} />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-50 pt-2">
|
||||
<div className="absolute right-0 top-full z-50 pt-1.5">
|
||||
<div
|
||||
role="menu"
|
||||
className="flex min-w-[9rem] flex-col gap-1.5 rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
|
||||
className="flex min-w-[8rem] flex-col overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
|
|
@ -81,8 +81,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
role="menuitem"
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={cn(
|
||||
'cursor-pointer rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
currentLangCode === lang.code ? 'bg-accent' : ''
|
||||
'w-full cursor-pointer rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
currentLangCode === lang.code && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{lang.name}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export function NamespaceBadge({ type, name, className }: NamespaceBadgeProps) {
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium border transition-colors',
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border transition-colors',
|
||||
type === 'GLOBAL'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/15'
|
||||
: 'bg-accent/10 text-accent border-accent/20 hover:bg-accent/15',
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,4 +13,8 @@ describe('pagination module exports', () => {
|
|||
it('exports the Pagination component', () => {
|
||||
expect(mod.Pagination).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('bounds long page ranges with first, current neighbours, and last page', () => {
|
||||
expect(mod.buildPageItems(5, 12)).toEqual([0, 'ellipsis', 4, 5, 6, 'ellipsis', 11])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ type PageItem = number | 'ellipsis'
|
|||
* the current page, and one neighbour on each side, collapsing the rest into
|
||||
* ellipsis markers. Pages are 0-indexed internally; labels are 1-indexed.
|
||||
*/
|
||||
function buildPageItems(current: number, totalPages: number): PageItem[] {
|
||||
export function buildPageItems(current: number, totalPages: number): PageItem[] {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i)
|
||||
}
|
||||
|
|
@ -45,18 +45,18 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||
const pageItems = buildPageItems(page, totalPages)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<nav aria-label={t('pagination.label')} className="flex flex-wrap items-center justify-center gap-2 py-4 sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 0}
|
||||
className="min-w-[90px]"
|
||||
className="min-w-0 flex-1 sm:flex-none sm:min-w-[90px]"
|
||||
>
|
||||
{t('pagination.prev')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="order-3 flex w-full items-center justify-center gap-1.5 sm:order-none sm:w-auto">
|
||||
{pageItems.map((item, index) =>
|
||||
item === 'ellipsis' ? (
|
||||
<span
|
||||
|
|
@ -88,10 +88,10 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="min-w-[90px]"
|
||||
className="min-w-0 flex-1 sm:flex-none sm:min-w-[90px]"
|
||||
>
|
||||
{t('pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,29 +23,29 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
|
|||
title={label}
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
'group relative inline-flex h-11 w-16 shrink-0 items-center rounded-full border border-border bg-muted/70 px-1 text-muted-foreground shadow-sm transition-[background-color,border-color] duration-200 hover:border-primary/40 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'group relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-border/50 bg-muted/60 px-0.5 text-muted-foreground transition-[background-color,border-color] duration-150 hover:border-border focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/15',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute left-1 top-1.5 h-8 w-7 rounded-full border border-border/80 bg-card shadow-[0_3px_10px_-4px_hsl(var(--foreground)/0.45)] transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
isDark && 'translate-x-7',
|
||||
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full border border-border/50 bg-card shadow-[0_1px_2px_0_rgb(0_0_0/0.12)] transition-[transform,box-shadow] duration-150 ease-out motion-reduce:transition-none',
|
||||
isDark && 'translate-x-[20px]',
|
||||
)}
|
||||
/>
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<span className="relative z-10 inline-flex h-5 w-5 items-center justify-center">
|
||||
<Sun
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', !isDark && 'text-foreground')}
|
||||
className={cn('h-3 w-3 transition-colors duration-150', !isDark && 'text-foreground')}
|
||||
/>
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex h-8 w-7 items-center justify-center">
|
||||
<span className="relative z-10 inline-flex h-5 w-5 items-center justify-center">
|
||||
<Moon
|
||||
aria-hidden="true"
|
||||
className={cn('h-4 w-4 transition-colors duration-200', isDark && 'text-foreground')}
|
||||
className={cn('h-3 w-3 transition-colors duration-150', isDark && 'text-foreground')}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,8 +76,8 @@ describe('user-menu module exports', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('UserMenu security settings visibility', () => {
|
||||
it('keeps author review progress separate from reviewer management', () => {
|
||||
describe('UserMenu navigation', () => {
|
||||
it('keeps dashboard-only personal links out of the compact avatar menu', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
|
|
@ -87,36 +87,69 @@ describe('UserMenu security settings visibility', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(html).toContain('user.menu.reviewProgress')
|
||||
expect(html).not.toContain('user.menu.reviews')
|
||||
expect(html).toContain('user.menu.dashboard')
|
||||
expect(html).not.toContain('user.menu.reviewProgress')
|
||||
expect(html).not.toContain('user.menu.security')
|
||||
})
|
||||
|
||||
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
|
||||
it('keeps administrator links available to administrators', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'OAuth Linked User',
|
||||
oauthProvider: 'github',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: true,
|
||||
displayName: 'Administrator',
|
||||
platformRoles: ['SUPER_ADMIN'],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).toContain('user.menu.security')
|
||||
expect(html).toContain('user.menu.users')
|
||||
expect(html).toContain('user.menu.labels')
|
||||
expect(html).toContain('user.menu.namespacesAdmin')
|
||||
expect(html).toContain('user.menu.auditLog')
|
||||
})
|
||||
|
||||
it('hides security settings when password changes are not allowed, even for a local-looking account', () => {
|
||||
it.each(['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'])(
|
||||
'keeps the review center available to %s users',
|
||||
(role) => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu user={{ displayName: 'Reviewer', platformRoles: [role] }} />,
|
||||
)
|
||||
|
||||
expect(html).toContain('href="/dashboard/reviews"')
|
||||
expect(html).toContain('user.menu.reviews')
|
||||
},
|
||||
)
|
||||
|
||||
it('does not show the global review center to regular users', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu user={{ displayName: 'Regular User', platformRoles: ['USER'] }} />,
|
||||
)
|
||||
|
||||
expect(html).not.toContain('href="/dashboard/reviews"')
|
||||
})
|
||||
|
||||
it('shows security settings only for accounts that can change a password', () => {
|
||||
const enabledHtml = renderToStaticMarkup(
|
||||
<UserMenu user={{ displayName: 'Local User', platformRoles: ['USER'], canChangePassword: true }} />,
|
||||
)
|
||||
const disabledHtml = renderToStaticMarkup(
|
||||
<UserMenu user={{ displayName: 'OAuth User', platformRoles: ['USER'], canChangePassword: false }} />,
|
||||
)
|
||||
|
||||
expect(enabledHtml).toContain('user.menu.security')
|
||||
expect(disabledHtml).not.toContain('user.menu.security')
|
||||
})
|
||||
|
||||
it('always keeps logout available', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'Local User',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: false,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).not.toContain('user.menu.security')
|
||||
expect(html).toContain('user.menu.logout')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths'
|
||||
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { canAccessGlobalReviewCenter } from '@/features/review/review-paths'
|
||||
import { withBasePath } from '@/shared/lib/base-path'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
|
|
@ -26,20 +24,16 @@ interface UserMenuProps {
|
|||
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: myNamespaces } = useMyNamespaces()
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isClickOpen, setIsClickOpen] = useState(false)
|
||||
|
||||
const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false
|
||||
const canSeeGovernance = canViewGovernanceCenter(user.platformRoles)
|
||||
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
|
||||
const isSuperAdmin = hasRole('SUPER_ADMIN')
|
||||
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
|
||||
const canChangePassword = user.canChangePassword === true
|
||||
const canReview = canAccessGlobalReviewCenter(user.platformRoles)
|
||||
const open = isHovered || isClickOpen
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
|
|
@ -122,15 +116,19 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
className={cn('flex items-center gap-3 text-foreground hover:opacity-80 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-md', triggerClassName)}
|
||||
onClick={() => setIsClickOpen((current) => !current)}
|
||||
>
|
||||
{user.avatarUrl && (
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.displayName}
|
||||
loading="lazy"
|
||||
className="w-8 h-8 rounded-full border border-border/60"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-accent text-sm font-semibold text-accent-foreground flex-shrink-0">
|
||||
{user.displayName?.charAt(0) ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden text-sm font-medium text-inherit lg:inline">
|
||||
<span className="sr-only">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -147,42 +145,17 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
<Link to="/dashboard" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.dashboard')}
|
||||
</Link>
|
||||
<Link to="/dashboard/skills" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.mySkills')}
|
||||
</Link>
|
||||
<Link to="/dashboard/namespaces" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.myNamespaces')}
|
||||
</Link>
|
||||
{canSeeGovernance ? (
|
||||
<Link to="/dashboard/governance" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.governance')}
|
||||
{user.canChangePassword === true ? (
|
||||
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.security')}
|
||||
</Link>
|
||||
) : null}
|
||||
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.stars')}
|
||||
</Link>
|
||||
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.subscriptions')}
|
||||
</Link>
|
||||
<Link to="/dashboard/review-progress" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviewProgress')}
|
||||
</Link>
|
||||
{reviewCenterVisible ? (
|
||||
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
|
||||
{canReview ? (
|
||||
<Link to="/dashboard/reviews" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviews')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isSkillAdmin ? (
|
||||
<Link to="/dashboard/promotions" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.promotions')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isSkillAdmin ? (
|
||||
<Link to="/dashboard/reports" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reports')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isUserAdmin || isAuditor || isSuperAdmin ? <div className="-mx-1 my-1 h-px bg-muted" /> : null}
|
||||
{canReview || isUserAdmin || isAuditor || isSuperAdmin ? <div className="-mx-1 my-1 h-px bg-muted" /> : null}
|
||||
{isUserAdmin ? (
|
||||
<Link to="/admin/users" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.users')}
|
||||
|
|
@ -204,18 +177,6 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
</Link>
|
||||
) : null}
|
||||
<div className="-mx-1 my-1 h-px bg-muted" />
|
||||
<Link to="/settings/profile" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.profile')}
|
||||
</Link>
|
||||
<Link to="/settings/notifications" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.notifications')}
|
||||
</Link>
|
||||
{canChangePassword ? (
|
||||
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.security')}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="-mx-1 my-1 h-px bg-muted" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { shouldFallbackToLegacyNamespaceList } from './use-namespace-queries'
|
||||
|
||||
/**
|
||||
* use-namespace-queries.ts exports React hooks that wrap @tanstack/react-query
|
||||
|
|
@ -25,4 +27,11 @@ describe('use-namespace-queries exports', () => {
|
|||
expect(typeof mod.useArchiveNamespace).toBe('function')
|
||||
expect(typeof mod.useRestoreNamespace).toBe('function')
|
||||
})
|
||||
|
||||
it('falls back to the legacy list only when the paginated endpoint is absent', () => {
|
||||
expect(shouldFallbackToLegacyNamespaceList(new ApiError('not found', 404))).toBe(true)
|
||||
expect(shouldFallbackToLegacyNamespaceList(new ApiError('unauthorized', 401))).toBe(false)
|
||||
expect(shouldFallbackToLegacyNamespaceList(new ApiError('server error', 500))).toBe(false)
|
||||
expect(shouldFallbackToLegacyNamespaceList(new TypeError('network error'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse, PagedResponse } from '@/api/types'
|
||||
import { namespaceApi } from '@/api/client'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import { replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache'
|
||||
import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers'
|
||||
|
||||
|
|
@ -8,6 +9,32 @@ async function getMyNamespaces(): Promise<ManagedNamespace[]> {
|
|||
return namespaceApi.listMine()
|
||||
}
|
||||
|
||||
export function shouldFallbackToLegacyNamespaceList(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 404
|
||||
}
|
||||
|
||||
async function getMyNamespacesPage(params: { page?: number; size?: number } = {}): Promise<PagedResponse<ManagedNamespace>> {
|
||||
try {
|
||||
return await namespaceApi.listMinePage(params)
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToLegacyNamespaceList(error)) {
|
||||
throw error
|
||||
}
|
||||
// Local development may still be backed by an older server image without the paginated endpoint.
|
||||
// Fall back to the legacy list endpoint and slice client-side so the page remains usable.
|
||||
const namespaces = await namespaceApi.listMine()
|
||||
const page = params.page ?? 0
|
||||
const size = params.size ?? 10
|
||||
const start = page * size
|
||||
return {
|
||||
items: namespaces.slice(start, start + size),
|
||||
total: namespaces.length,
|
||||
page,
|
||||
size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {
|
||||
return namespaceApi.create(request)
|
||||
}
|
||||
|
|
@ -58,6 +85,14 @@ export function useMyNamespaces() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useMyNamespacesPage(params: { page?: number; size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['namespaces', 'my', 'page', params],
|
||||
queryFn: () => getMyNamespacesPage(params),
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateNamespace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { buttonVariants } from './button'
|
|||
describe('buttonVariants', () => {
|
||||
it('applies default variant and size classes', () => {
|
||||
const classes = buttonVariants()
|
||||
expect(classes).toContain('bg-brand-gradient')
|
||||
expect(classes).toContain('h-10')
|
||||
expect(classes).toContain('px-5')
|
||||
expect(classes).toContain('bg-primary')
|
||||
expect(classes).toContain('h-9')
|
||||
expect(classes).toContain('px-4')
|
||||
})
|
||||
|
||||
it('applies destructive variant classes', () => {
|
||||
|
|
@ -17,7 +17,7 @@ describe('buttonVariants', () => {
|
|||
it('applies outline variant classes', () => {
|
||||
const classes = buttonVariants({ variant: 'outline' })
|
||||
expect(classes).toContain('border')
|
||||
expect(classes).toContain('bg-transparent')
|
||||
expect(classes).toContain('bg-background/70')
|
||||
})
|
||||
|
||||
it('applies secondary variant classes', () => {
|
||||
|
|
@ -27,7 +27,7 @@ describe('buttonVariants', () => {
|
|||
|
||||
it('applies ghost variant classes', () => {
|
||||
const classes = buttonVariants({ variant: 'ghost' })
|
||||
expect(classes).toContain('hover:bg-secondary')
|
||||
expect(classes).toContain('hover:bg-accent/70')
|
||||
})
|
||||
|
||||
it('applies link variant classes', () => {
|
||||
|
|
@ -43,14 +43,14 @@ describe('buttonVariants', () => {
|
|||
|
||||
it('applies lg size classes', () => {
|
||||
const classes = buttonVariants({ size: 'lg' })
|
||||
expect(classes).toContain('h-12')
|
||||
expect(classes).toContain('text-base')
|
||||
expect(classes).toContain('h-10')
|
||||
expect(classes).toContain('px-8')
|
||||
})
|
||||
|
||||
it('applies icon size classes', () => {
|
||||
const classes = buttonVariants({ size: 'icon' })
|
||||
expect(classes).toContain('h-10')
|
||||
expect(classes).toContain('w-10')
|
||||
expect(classes).toContain('h-9')
|
||||
expect(classes).toContain('w-9')
|
||||
})
|
||||
|
||||
it('always includes base focus-visible and disabled styles', () => {
|
||||
|
|
|
|||
|
|
@ -3,28 +3,30 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
// 基础:紧致圆角、精准属性过渡、按下回弹、聚焦双层光晕
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,box-shadow,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// default:三层阴影(inset 顶高光 + 底部投影 + 1px 描边),模拟立体片状按钮
|
||||
default:
|
||||
'bg-brand-gradient text-white shadow-sm hover:opacity-95 active:scale-[0.98]',
|
||||
'bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.10),0_1px_2px_0_rgb(0_0_0/0.10),0_0_0_1px_rgb(0_0_0/0.04)] hover:bg-primary/92 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.14),0_2px_4px_-1px_rgb(0_0_0/0.14),0_0_0_1px_rgb(0_0_0/0.06)] active:shadow-[inset_0_1px_2px_0_rgb(0_0_0/0.14)]',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
'bg-destructive text-destructive-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.12),0_1px_2px_0_rgb(0_0_0/0.10)] hover:bg-destructive/92 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.16),0_2px_4px_-1px_rgb(0_0_0/0.14)]',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-secondary hover:border-primary/30 hover:text-foreground active:scale-[0.98]',
|
||||
'border border-border/60 bg-background/70 text-foreground shadow-[0_1px_1px_0_rgb(0_0_0/0.04)] hover:bg-accent/80 hover:border-border hover:text-accent-foreground hover:shadow-[0_1px_2px_0_rgb(0_0_0/0.06)]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
'bg-secondary text-secondary-foreground shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.04),0_1px_1px_0_rgb(0_0_0/0.04)] hover:bg-secondary/80 hover:shadow-[inset_0_1px_0_0_hsl(0_0%_100%/0.06),0_1px_2px_0_rgb(0_0_0/0.06)]',
|
||||
ghost:
|
||||
'hover:bg-secondary hover:text-foreground',
|
||||
'hover:bg-accent/70 hover:text-accent-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
'text-primary underline-offset-4 hover:underline active:scale-100',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-12 rounded-xl px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-sm px-3 text-xs',
|
||||
lg: 'h-10 rounded-lg px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -39,10 +41,11 @@ export interface ButtonProps
|
|||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
({ className, variant, size, style, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
style={style}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import * as React from 'react'
|
|||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
({ className, style, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md',
|
||||
'rounded-md border border-border/60 bg-card text-card-foreground transition-[border-color,box-shadow,transform] duration-150 ease-out hover:border-[hsl(214_50%_88%)] hover:shadow-[var(--shadow-card-hover)]',
|
||||
className
|
||||
)}
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
style={{ boxShadow: 'var(--shadow-card)', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -18,14 +18,14 @@ Card.displayName = 'Card'
|
|||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('text-xl font-semibold font-heading leading-none tracking-tight', className)} {...props} />
|
||||
<h3 ref={ref} className={cn('text-lg font-semibold font-heading leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
|
@ -38,13 +38,13 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
|
|||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
translate="no"
|
||||
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
|
||||
className={cn('fixed inset-0 z-50 bg-black/40 backdrop-blur-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
@ -36,13 +36,13 @@ const DialogContent = React.forwardRef<
|
|||
ref={ref}
|
||||
translate="no"
|
||||
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',
|
||||
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),30rem)] -translate-x-1/2 -translate-y-1/2 gap-5 overflow-y-auto rounded-2xl border border-border/40 bg-card text-card-foreground p-6 shadow-[0_20px_60px_-12px_rgb(0_0_0/0.30),0_0_0_1px_rgb(0_0_0/0.04)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close 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">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-lg p-1.5 text-muted-foreground/60 transition-all duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none">
|
||||
<span className="sr-only">Close</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -66,7 +66,7 @@ const DialogContent = React.forwardRef<
|
|||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center', className)} {...props} />
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ const DialogTitle = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)}
|
||||
className={cn('text-lg font-semibold leading-tight tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
@ -93,7 +93,7 @@ const DialogDescription = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-center text-sm text-muted-foreground', className)}
|
||||
className={cn('text-sm text-muted-foreground/80', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
|
|||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const INPUT_BASE_CLASS_NAME =
|
||||
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
'flex h-9 w-full rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/70 hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-background focus-visible:ring-4 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, style, ...props }, ref) => {
|
||||
|
|
@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
INPUT_BASE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
style={{ borderColor: 'hsl(var(--border))', ...style }}
|
||||
style={{ ...style }}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ import {
|
|||
|
||||
describe('shared select contract', () => {
|
||||
it('keeps the trigger aligned with the existing input styling language', () => {
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-11')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('rounded-lg')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-9')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('rounded-md')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('border-border/60')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('bg-secondary/50')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('bg-background/40')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:outline-none')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-2')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-primary/40')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:border-primary/50')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-4')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:ring-ring/15')
|
||||
expect(SELECT_TRIGGER_CLASS_NAME).toContain('focus-visible:border-ring')
|
||||
})
|
||||
|
||||
it('uses themed panel and item classes for the floating listbox', () => {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { getPortalContainer } from '@/shared/lib/portal-container'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
export const SELECT_TRIGGER_CLASS_NAME = cn(
|
||||
'flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-secondary/50 px-4 py-2 text-sm text-foreground',
|
||||
'ring-offset-background transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50',
|
||||
'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground',
|
||||
'ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out',
|
||||
'hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-card focus-visible:ring-4 focus-visible:ring-ring/15',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1'
|
||||
)
|
||||
|
||||
export const SELECT_CONTENT_CLASS_NAME = cn(
|
||||
'z-50 max-h-[var(--radix-select-content-available-height)] overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
|
||||
'z-50 max-h-[var(--radix-select-content-available-height)] w-fit min-w-48 max-w-[18rem] overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
|
||||
// In-tree (no Portal): avoids React 19 removeChild races on route unmount.
|
||||
// No exit animations: delayed unmount still races commits when Content was portaled.
|
||||
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
|
|
@ -89,33 +90,43 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
|||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', sideOffset = 4, ...props }, ref) => (
|
||||
// No Portal: Content stays in the React tree with its trigger so route/Dialog
|
||||
// unmount cannot orphan a body/#skillhub-portals node (removeChild).
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
SELECT_CONTENT_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
>(({ className, children, position = 'popper', sideOffset = 4, ...props }, ref) => {
|
||||
const portalContainer = getPortalContainer()
|
||||
const content = (
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
translate="no"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper'
|
||||
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
|
||||
SELECT_CONTENT_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
))
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper'
|
||||
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
)
|
||||
|
||||
// Portal to the shared container when available (e.g. inside a Dialog) so
|
||||
// the dropdown is not clipped by overflow:hidden / overflow-y-auto ancestors.
|
||||
// In-tree still works fine for non-modal contexts.
|
||||
if (portalContainer) {
|
||||
return React.createElement(SelectPrimitive.Portal, { container: portalContainer }, content)
|
||||
}
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ const config: Config = {
|
|||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
md: 'var(--radius)',
|
||||
sm: 'calc(var(--radius) - 2px)',
|
||||
xl: 'calc(var(--radius) + 4px)',
|
||||
'2xl': 'calc(var(--radius) + 8px)',
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue