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:
XiaoSeS 2026-09-09 18:31:18 +08:00 committed by GitHub
parent 3fd8c63fe5
commit beecc34b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 2597 additions and 1016 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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());

View file

@ -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);

View file

@ -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);
}

View file

@ -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()
})
})

View file

@ -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')

View file

@ -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()
})
})

View file

@ -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 }) => {

View file

@ -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)

View file

@ -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.

View file

@ -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": {

View file

@ -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)}`)
},

View file

@ -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;

View file

@ -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')
})

View file

@ -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)

View file

@ -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', () => {

View file

@ -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,

View file

@ -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>

View file

@ -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'),

View file

@ -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) => {

View file

@ -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,
})
}

View file

@ -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}

View file

@ -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>

View file

@ -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, {

View file

@ -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>

View file

@ -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>

View file

@ -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",

View file

@ -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": "Убрать из избранного",

View file

@ -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": "发布技能",

View file

@ -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);
}

View file

@ -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')
})
})

View file

@ -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>
)
}

View file

@ -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 }),
}))

View file

@ -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')}

View file

@ -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
}
/>
)}

View file

@ -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 ? (

View file

@ -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)
})
})

View file

@ -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>
)
}

View file

@ -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}

View file

@ -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')}

View file

@ -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')
})
})

View file

@ -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>
</>
)
}

View file

@ -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 />)

View file

@ -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"

View file

@ -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>
)
}

View file

@ -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', () => ({

View file

@ -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')}

View file

@ -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}>

View 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>
)
}

View file

@ -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>

View file

@ -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',
)
})
})
})

View file

@ -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>

View file

@ -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}

View file

@ -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
)}
>

View file

@ -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])
})
})

View file

@ -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>
)
}

View file

@ -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>
)
}
}

View file

@ -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')
})
})

View file

@ -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}

View file

@ -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)
})
})

View file

@ -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()

View file

@ -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', () => {

View file

@ -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}
/>

View file

@ -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'

View file

@ -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}
/>
))

View file

@ -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}
/>

View file

@ -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', () => {

View file

@ -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

View file

@ -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)',
},