fix(web): align my skills page with approved prototype

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-04 11:39:21 +08:00
parent ac593567ca
commit ce42c883ba
10 changed files with 307 additions and 73 deletions

View file

@ -1,7 +1,7 @@
{
"schema": 1,
"description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.",
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":822,"literalJsxValues":976,"navigationDefinitions":29,"forwardingSites":36},
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":823,"literalJsxValues":977,"navigationDefinitions":29,"forwardingSites":36},
"routes": [
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false},
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
@ -19,7 +19,7 @@
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":5,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":53,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":54,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
@ -547,33 +547,34 @@
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":138,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.open"]},
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":148,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.list.paginate"]},
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":157,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":358,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":383,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":387,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":389,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":401,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":419,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":453,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":477,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":503,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":506,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":510,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":512,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":515,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":517,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":520,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":549,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":558,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":580,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":580,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":609,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":609,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":362,"host":"form","prop":"data-analytics-id","event":"submit","ids":["skill.owned.search"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":387,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["skill.owned.namespace.interact"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":391,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":393,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["skill.owned.namespace"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":405,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.filter"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":423,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":457,"host":"Card","prop":"data-analytics-id","event":"click","ids":["skill.owned.open"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":481,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":507,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.update.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":511,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.related.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":514,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.visibility.open"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":518,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.review.withdraw"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":520,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.promotion.create.request","skill.promotion.update.request"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":523,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.unarchive.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":525,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.archive.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":528,"host":"DropdownMenuItem","prop":"data-analytics-id","event":"click","ids":["skill.restore.request"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":562,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.owned.paginate"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":571,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.owned.clear"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.restore.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":593,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.restore.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":606,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.promotion.create.confirm","skill.promotion.update.confirm"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":606,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.promotion.create.cancel","skill.promotion.update.cancel"],"conditional":true},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.archive.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":622,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.archive.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.unarchive.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":635,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.unarchive.cancel"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":648,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["skill.review.withdraw.confirm"]},
{"file":"web/src/pages/dashboard/my-skills.tsx","line":648,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["skill.review.withdraw.cancel"]},
{"file":"web/src/pages/dashboard/my-suites.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.create.open"]},
{"file":"web/src/pages/dashboard/my-suites.tsx","line":57,"host":"form","prop":"data-analytics-id","event":"submit","ids":["suite.owned.search"]},
{"file":"web/src/pages/dashboard/my-suites.tsx","line":63,"host":"button","prop":"data-analytics-id","event":"click","ids":["suite.query.clear"]},

View file

@ -37,3 +37,18 @@
## 验证方式
先完成一个可执行筛选切片并创建 Draft PR,再完成卡片和操作。实现收敛后执行一次集中独立 Review,随后冻结代码,形成需求追踪矩阵并运行源检查、精确提交镜像及浏览器关键业务路径。截图只证明视觉结果,不替代接口与最终状态断言。
## 原型视觉基线与页面隔离
以已确认原生 Figma 稿及最终执行生成脚本为视觉来源,原生 Medium 字重采用500;本地 HTML 仅作辅助对照;旧覆盖前的30px操作高度不作为基线。所有尺寸仅作用于“我的技能”,不改变共享组件或其他管理页。
| 区域 | 本页基线 | 生产适配 |
| --- | --- | --- |
| 标题/简介/元数据/状态 | 15px/24px、12px/20px、11px/18px、10px/20px | 窄屏不进一步缩字,标题最多两行,完整名称保留于title及详情 |
| 状态与图标 | 状态圆角5px、内边距0/7px、低饱和底色;元数据图标13px | 小字前景色加深到4.5:1对比;真实头像或首字回退32px,不推断技能类别 |
| 卡片与操作 | 圆角12px、卡片间距12px、桌面内边距18px/20px、移动14px | 更新72×32px、更多32×32px;768px以下更新进入菜单,保留相同权限及预填参数 |
| 页面框架 | 仅/dashboard/skills使用1440px外壳,桌面右栏左右40px、顶部36px | 手机左右16px、顶部18px;仅本页侧栏添加桌面20px水平/32px顶部及移动16px内边距,公共导航及侧栏行为和其他管理页保持原样 |
| 筛选与菜单 | 筛选控件40px、胶囊32px;菜单宽208px、圆角10px、条目36px、图标16px;发布按钮36px | 不伪造状态计数;浅色与深色分别适配 |
原型中的彩色类别头像与固定技能数据是演示样例,不能据此生成生产语义;真实技能图片、生命周期、只读状态与审核目标仍来自现有接口。短可见性标签和图标旁保留完整范围说明,长坐标可截断但保留完整悬停文本及复制操作。公共导航高度及内容继续遵循应用外壳,不宣称整个应用逐像素复刻原型。

View file

@ -51,3 +51,9 @@
- **WHEN** 菜单操作完成,查询结果移除了原技能行及其操作入口
- **THEN** 若焦点因入口消失落到页面主体,焦点返回列表搜索控件
- **AND** 原行保留时恢复原入口;用户已主动聚焦其他控件时不抢夺焦点
#### Scenario: 原型视觉基线与其他页面隔离
- **WHEN** 用户打开“我的技能”列表
- **THEN** 卡片、筛选、标签和图标按设计文档的本页量化视觉基线展示,窄屏保留菜单内更新操作
- **AND** 仅该路由使用宽版内容框架,其他管理页及公共导航不受影响

View file

@ -12,6 +12,7 @@
## 3. 验证与交付
- [x] 筛选首片针对性测试与静态检查
- [x] 创建 Draft PR 并持续更新(#1062)
- [ ] 集中独立 Review 与修复
- [x] 集中独立 Review 与修复(功能、子路径与焦点问题已修复并复核)
- [ ] 原型视觉专项:图标、字号、标签、路由框架及明暗/移动端浏览器对照
- [ ] 冻结代码后的需求场景矩阵、源检查与精确提交运行时验证
- [ ] 保留人工预览入口并交付验证结果

View file

@ -5,6 +5,10 @@ import {
CENTERED_MAIN_CLASS_NAME,
CENTERED_SEARCH_CONTENT_CLASS_NAME,
DEFAULT_MAIN_CLASS_NAME,
MY_SKILLS_MAIN_CLASS_NAME,
MY_SKILLS_CONTENT_CLASS_NAME,
MY_SKILLS_SIDEBAR_ROW_CLASS_NAME,
MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME,
SPLIT_AUTH_MAIN_CLASS_NAME,
getAppMainContentLayout,
resolveAppMainContentPathname,
@ -28,8 +32,8 @@ describe('getAppMainContentLayout', () => {
expect(layout.contentClassName).toContain('max-w-[1200px]')
})
it('centers all dashboard sub-pages within a slightly narrower content frame', () => {
const layout = getAppMainContentLayout('/dashboard/skills')
it.each(['/dashboard/tokens', '/dashboard/stars', '/settings/profile', '/dashboard/skills/unrelated'])('keeps %s in the existing dashboard frame', (pathname) => {
const layout = getAppMainContentLayout(pathname)
expect(layout).toEqual({
mainClassName: CENTERED_MAIN_CLASS_NAME,
@ -38,6 +42,16 @@ describe('getAppMainContentLayout', () => {
expect(layout.contentClassName).toContain('max-w-[1100px]')
})
it('isolates the prototype-width frame and sidebar gutters to My Skills', () => {
expect(getAppMainContentLayout('/dashboard/skills')).toEqual({
mainClassName: MY_SKILLS_MAIN_CLASS_NAME,
contentClassName: MY_SKILLS_CONTENT_CLASS_NAME,
sidebarRowClassName: MY_SKILLS_SIDEBAR_ROW_CLASS_NAME,
sidebarContentClassName: MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME,
})
expect(MY_SKILLS_CONTENT_CLASS_NAME).toContain('max-w-[1440px]')
})
it('uses the full-bleed split layout for login and registration', () => {
for (const pathname of ['/login', '/register']) {
expect(getAppMainContentLayout(pathname)).toEqual({

View file

@ -5,6 +5,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-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 min-h-[calc(100vh-11rem)] w-full max-w-[1100px]'
export const MY_SKILLS_MAIN_CLASS_NAME = 'flex-1 relative z-10 bg-background'
export const MY_SKILLS_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1440px]'
export const MY_SKILLS_SIDEBAR_ROW_CLASS_NAME = 'flex flex-col lg:flex-row gap-0 [&>aside]:p-4 lg:[&>aside]:px-5 lg:[&>aside]:pt-8 lg:[&>aside]:pb-6 lg:[&>aside]:border-r lg:[&>aside]:border-border/40'
export const MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME = 'flex-1 min-w-0 px-4 pb-7 pt-[18px] lg:px-10 lg:pt-9'
export const REVIEW_DETAIL_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1500px]'
export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const
@ -14,6 +18,8 @@ export const SPLIT_AUTH_PATHS = ['/login', '/register'] as const
interface AppMainContentLayout {
mainClassName: string
contentClassName: string
sidebarRowClassName?: string
sidebarContentClassName?: string
}
export function resolveAppMainContentPathname(
@ -59,6 +65,15 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout
}
}
if (pathname === '/dashboard/skills') {
return {
mainClassName: MY_SKILLS_MAIN_CLASS_NAME,
contentClassName: MY_SKILLS_CONTENT_CLASS_NAME,
sidebarRowClassName: MY_SKILLS_SIDEBAR_ROW_CLASS_NAME,
sidebarContentClassName: MY_SKILLS_SIDEBAR_CONTENT_CLASS_NAME,
}
}
if (pathname === '/dashboard' || pathname.startsWith('/dashboard/') || pathname.startsWith('/settings/')) {
return {
mainClassName: CENTERED_MAIN_CLASS_NAME,

View file

@ -219,9 +219,9 @@ export function Layout() {
>
<div className={mainContentLayout.contentClassName}>
{showSidebar ? (
<div className="flex flex-col lg:flex-row gap-6">
<div className={mainContentLayout.sidebarRowClassName ?? "flex flex-col lg:flex-row gap-6"}>
<DashboardSidebar groups={filteredDashboardGroups} user={user} t={t} pathname={pathname} />
<div className="flex-1 min-w-0">
<div className={mainContentLayout.sidebarContentClassName ?? "flex-1 min-w-0"}>
<Outlet />
</div>
</div>

View file

@ -282,4 +282,16 @@ describe('My Skills interactions with real menu and dialog', () => {
expect(document.activeElement?.isConnected).toBe(true)
})
it('opens the existing prefilled update form from the mobile menu action', async () => {
render(<MySkillsPage />)
await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'mySkills.update' }))
expect(navigate).toHaveBeenCalledOnce()
expect(navigate).toHaveBeenCalledWith({
to: '/dashboard/publish',
search: { namespace: 'team', updateSkill: 'demo', updateName: 'Demo', visibility: 'PRIVATE' },
})
})
})

View file

@ -0,0 +1,157 @@
/* Page-local density follows the confirmed My Skills prototype, not shared UI defaults. */
.page .filterPanel {
border-radius: 12px;
margin-top: 26px;
padding: 16px;
background: #fafafa;
border-color: #e9e9ed;
}
.page .filterControl {
height: 40px;
border-radius: 8px;
font-size: 13px;
box-shadow: none;
border-color: #dedee5;
}
.page .filterButton {
height: 32px;
min-width: 85px;
padding: 0 14px;
border-radius: 999px;
font-size: 12px;
box-shadow: none;
}
.page .resultCount {
gap: 12px;
font-size: 11px;
line-height: 20px;
}
.page .clearButton {
height: 24px;
padding: 0;
font-size: 11px;
}
.page .skillCard {
padding: 18px 20px;
border-radius: 12px;
box-shadow: none;
border-color: #dedee5;
}
.page .skillTitle {
font-size: 15px;
font-weight: 500;
line-height: 24px;
letter-spacing: .05px;
}
.page .status {
display: inline-flex;
margin: 0;
padding: 0 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 500;
line-height: 20px;
color: #666670;
background: #f1f2f4;
}
.page .statusPublished { color: #357548; background: #eef7f0; }
.page .statusReview { color: #8b631f; background: #fff5e6; }
.page .statusRejected { color: #a64d46; background: #fff0ef; }
:global(.dark) .page .status { color: #b9b9c2; background: #303039; }
:global(.dark) .page .statusPublished { color: #97d5ac; background: #193329; }
:global(.dark) .page .statusReview { color: #e7bf78; background: #3b2d19; }
:global(.dark) .page .statusRejected { color: #efa49b; background: #402625; }
.page .description {
font-size: 12px;
line-height: 20px;
color: #666670;
}
.page .metadata {
gap: 8px 17px;
font-size: 11px;
line-height: 18px;
color: #73737d;
}
.page .metadataItem {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.page .coordinate { max-width: 210px; color: #60738d; }
:global(.dark) .page .coordinate { color: #a1b5d0; }
.page .pendingVersion { color: #8b631f; }
:global(.dark) .page .pendingVersion { color: #d9b77e; }
.page .updateButton,
.page .moreButton {
height: 32px;
border-radius: 8px;
box-shadow: none;
font-size: 12px;
}
.page .updateButton { gap: 6px; padding: 0 8px; min-width: 72px; }
.page .moreButton { width: 32px; padding: 0; color: hsl(var(--muted-foreground)); }
.page .note { font-size: 12px; line-height: 20px; }
@media (max-width: 639px) {
.page .filterPanel,
.page .skillCard { padding: 14px; }
.page .filterButton { min-width: 0; padding: 0 12px; }
.page .metadata { gap: 6px 12px; }
.page .coordinate { max-width: 150px; }
}
:global(.dark) .page .filterPanel { background: hsl(var(--muted) / .3); border-color: hsl(var(--border) / .6); }
:global(.dark) .page .skillCard,
:global(.dark) .page .filterControl { border-color: hsl(var(--border) / .7); }
:global(.dark) .page .description,
:global(.dark) .page .metadata { color: hsl(var(--muted-foreground)); }
.page h1 { font-size: 27px; line-height: 36px; font-weight: 500; letter-spacing: -.8px; }
.page .publishButton { height: 36px; padding: 0 12px; border-radius: 8px; font-size: 12px; }
.moreMenu.moreMenu { width: 208px; padding: 6px; border-radius: 10px; }
.moreMenu [role="menuitem"] { min-height: 36px; padding: 0 10px; font-size: 12px; }
@media (max-width: 767px) {
.page .desktopUpdate { display: none; }
.page .skillTitle { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.page .skillTitle a { white-space: normal; overflow: visible; }
}
@media (min-width: 768px) {
.moreMenu .mobileUpdate { display: none; }
}
.page .skillList { margin-top: 20px; }
.page h1 + p { font-size: 13px; line-height: 20px; }
.page .skillCard:hover { border-color: #c7c7d1; }
:global(.dark) .page .skillCard:hover { border-color: hsl(var(--border)); }
.page .updateButton:hover,
.page .moreButton:hover,
.page .publishButton { box-shadow: none; }
.page .updateButton svg,
.page .publishButton svg,
.moreMenu svg { stroke-width: 1.65; }
.page .filterControl::placeholder { color: #73737d; opacity: 1; }
:global(.dark) .page .filterControl::placeholder { color: #aaaab5; }
.moreMenu .dangerAction,
.moreMenu .dangerAction:focus,
.moreMenu .dangerAction[data-highlighted] { color: #b42318; }
:global(.dark) .moreMenu .dangerAction,
:global(.dark) .moreMenu .dangerAction:focus,
:global(.dark) .moreMenu .dangerAction[data-highlighted] { color: #ffb4ab; }

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Archive, ArrowUpRight, Copy, Download, Eye, GitCompareArrows, Globe, MoreHorizontal, RotateCcw, Search, Undo2, Upload } from 'lucide-react'
import { Archive, ArrowUpRight, Copy, Download, Eye, GitCompareArrows, Globe, LockKeyhole, MoreHorizontal, Package, Plus, RotateCcw, Search, Undo2, Upload, Users } from 'lucide-react'
import styles from './my-skills.module.css'
import { useLocation, useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
@ -201,28 +202,28 @@ export function MySkillsPage() {
const resolveStatusClassName = (status?: string) => {
if (status === 'HIDDEN') {
return 'status-pill status-pill--archived'
return 'status-pill'
}
if (status === 'ARCHIVED') {
return 'status-pill status-pill--archived'
return 'status-pill'
}
if (status === 'PENDING_REVIEW') {
return 'status-pill status-pill--review'
return `status-pill ${styles.statusReview}`
}
if (status === 'PUBLISHED') {
return 'status-pill status-pill--published'
return `status-pill ${styles.statusPublished}`
}
if (status === 'REJECTED') {
return 'status-pill status-pill--rejected'
return `status-pill ${styles.statusRejected}`
}
if (status === 'SCANNING') {
return 'status-pill status-pill--review'
return `status-pill ${styles.statusReview}`
}
if (status === 'SCAN_FAILED') {
return 'status-pill status-pill--rejected'
return `status-pill ${styles.statusRejected}`
}
if (status === 'UPLOADED') {
return 'status-pill status-pill--review'
return `status-pill ${styles.statusReview}`
}
return 'status-pill'
}
@ -347,13 +348,16 @@ export function MySkillsPage() {
}
return (
<div className="space-y-8 animate-fade-up">
<div className={`${styles.page} animate-fade-up`}>
<DashboardPageHeader
title={t('mySkills.title')}
subtitle={t('mySkills.subtitle')}
actions={<Button size="sm" className={styles.publishButton} onClick={() => navigate({ to: '/dashboard/publish' })}>
<Plus aria-hidden="true" className="h-4 w-4" />{t('mySkills.publishNew')}
</Button>}
/>
<section aria-label={t('mySkills.filterLabel')} className="rounded-[12px] border border-border/60 bg-muted/30 p-4">
<section aria-label={t('mySkills.filterLabel')} className={`${styles.filterPanel} border border-border/60`}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<form data-analytics-id="skill.owned.search" className="flex min-w-0 flex-1 gap-2" onSubmit={event => {
event.preventDefault()
@ -368,10 +372,10 @@ export function MySkillsPage() {
onChange={(event) => setKeywordInput(event.target.value)}
placeholder={t('mySkills.searchPlaceholder')}
aria-label={t('mySkills.searchPlaceholder')}
className="h-10 bg-background pl-9"
className={`${styles.filterControl} bg-background pl-9`}
/>
</div>
<Button type="submit" variant="outline" className="h-10">{t('nav.search')}</Button>
<Button type="submit" variant="outline" className={styles.filterControl}>{t('nav.search')}</Button>
</form>
{filter === 'HIDDEN' ? null : (
<Select
@ -380,7 +384,7 @@ export function MySkillsPage() {
updateSearch({ namespace: value === ALL_NAMESPACES_VALUE ? undefined : value, page: 0 })
}}
>
<SelectTrigger data-analytics-id="skill.owned.namespace.interact" aria-label={t('mySkills.namespaceFilterLabel')} className="h-10 bg-background sm:w-60 sm:shrink-0">
<SelectTrigger data-analytics-id="skill.owned.namespace.interact" aria-label={t('mySkills.namespaceFilterLabel')} className={`${styles.filterControl} bg-background sm:w-60 sm:shrink-0`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -402,7 +406,7 @@ export function MySkillsPage() {
key={option}
type="button"
size="sm"
className="rounded-full px-4 text-sm shadow-none"
className={styles.filterButton}
variant={filter === option ? 'default' : 'outline'}
aria-pressed={filter === option}
onClick={() => {
@ -413,10 +417,10 @@ export function MySkillsPage() {
</Button>
))}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className={`${styles.resultCount} flex items-center text-muted-foreground`}>
{skillPage ? <span aria-live="polite" aria-atomic="true">{t('mySkills.resultCount', { count: skillPage.total })}</span> : null}
{hasActiveFilters ? (
<Button data-analytics-id="skill.owned.clear" type="button" size="sm" variant="ghost" onClick={handleClearFilters}>
<Button data-analytics-id="skill.owned.clear" type="button" size="sm" variant="ghost" className={styles.clearButton} onClick={handleClearFilters}>
{t('mySkills.clearSearch')}
</Button>
) : null}
@ -426,7 +430,7 @@ export function MySkillsPage() {
{skillPage && skillPage.total > 0 ? (
<>
<div className="grid grid-cols-1 gap-3">
<div className={`${styles.skillList} grid grid-cols-1 gap-3`}>
{skills.map((skill) => {
const ownerReadOnly = skill.managementState?.ownerReadOnly === true
const hidden = filter === 'HIDDEN'
@ -451,16 +455,16 @@ export function MySkillsPage() {
return (
<Card key={skill.id} data-analytics-id={hidden ? undefined : 'skill.owned.open'}
className={`relative rounded-[12px] border-border/70 p-4 shadow-none hover:shadow-none sm:p-5 ${hidden ? '' : 'cursor-pointer hover:border-border'}`}
className={`${styles.skillCard} relative border-border/70 hover:shadow-none ${hidden ? '' : 'cursor-pointer hover:border-border'}`}
onClick={hidden ? undefined : openDetail}>
<div className="flex items-start gap-3">
<SkillAvatar name={displayName} url={skill.avatarUrl} />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<h3 className="min-w-0 break-words text-lg font-semibold [overflow-wrap:anywhere]">
<SkillAvatar name={displayName} url={skill.avatarUrl} size="h-8 w-8 text-xs" />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<h3 className={`${styles.skillTitle} min-w-0 flex-1 truncate`} title={displayName}>
{hidden ? displayName : <a href={withBasePath(`/space/${skill.namespace}/${encodeURIComponent(skill.slug)}?${detailSearch}`)}
className="rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="block truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={event => {
event.stopPropagation()
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
@ -468,28 +472,28 @@ export function MySkillsPage() {
openDetail()
}}>{displayName}</a>}
</h3>
{mainStatus && <span className={`${resolveStatusClassName(mainStatus)} shrink-0`}>
{mainStatus && <span className={`${resolveStatusClassName(mainStatus)} ${styles.status} shrink-0`}>
{hasPendingPreview && publishedVersion && mainStatus === 'PENDING_REVIEW'
? t('mySkills.statusUpdatePendingReview') : resolveStatusLabel(mainStatus)}
</span>}
</div>
<div className="flex shrink-0 items-center gap-2" onClick={event => event.stopPropagation()}>
{writable && skill.status !== 'ARCHIVED' && <Button data-analytics-id="skill.update.open" size="sm" variant="outline"
className="text-sm" onClick={event => {
className={`${styles.updateButton} ${styles.desktopUpdate}`} onClick={event => {
event.stopPropagation()
handleUpdateSkill(skill.namespace, skill.slug, displayName, skill.visibility ?? 'PUBLIC')
}}><Upload aria-hidden="true" className="h-4 w-4" />{t('mySkills.update')}</Button>}
{/* Keep the menu nonmodal so a new Dialog owns the focus trap during the handoff. */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" className="h-8 w-8"
<Button size="icon" variant="outline" className={styles.moreButton}
title={t('mySkills.moreActions')} aria-label={t('mySkills.moreActionsFor', { skill: displayName })}
onClick={event => { event.stopPropagation(); compareTrigger.current = event.currentTarget }}
onKeyDown={event => { compareTrigger.current = event.currentTarget }}>
<MoreHorizontal aria-hidden="true" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-1.5" onClick={event => event.stopPropagation()}
<DropdownMenuContent align="end" className={styles.moreMenu} onClick={event => event.stopPropagation()}
onCloseAutoFocus={event => { if (compareTarget || visibilityTarget || archiveTarget || unarchiveTarget || withdrawTarget || promotionTarget || restoreHiddenTarget) event.preventDefault() }}>
{!hidden && <DropdownMenuItem onSelect={openDetail}><Eye aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.viewDetail')}</DropdownMenuItem>}
<DropdownMenuItem onSelect={async () => {
@ -500,6 +504,10 @@ export function MySkillsPage() {
setCompareVersions({ from: '', to: '' })
setCompareTarget({ namespace: skill.namespace, slug: skill.slug, name: displayName })
}}><GitCompareArrows aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.compareVersions')}</DropdownMenuItem>}
{writable && skill.status !== 'ARCHIVED' && <DropdownMenuItem data-analytics-id="skill.update.open" className={styles.mobileUpdate}
onSelect={() => handleUpdateSkill(skill.namespace, skill.slug, displayName, skill.visibility ?? 'PUBLIC')}>
<Upload aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.update')}
</DropdownMenuItem>}
{skill.managementState?.promotionRole && relatedNamespace && relatedSlug && <DropdownMenuItem data-analytics-id="skill.related.open" onSelect={() => handleSkillClick(relatedNamespace, relatedSlug)}>
<ArrowUpRight aria-hidden="true" className="mr-2 h-4 w-4" />{t(skill.managementState?.promotionRole === 'TEAM_SOURCE' ? 'mySkills.viewGlobalCopy' : 'mySkills.viewTeamSource')}
</DropdownMenuItem>}
@ -514,7 +522,7 @@ export function MySkillsPage() {
<Globe aria-hidden="true" className="mr-2 h-4 w-4" />{t(skill.managementState?.promotionRole === 'TEAM_SOURCE' ? 'mySkills.updateGlobalVersion' : 'mySkills.promoteToGlobal')}
</DropdownMenuItem> : skill.status === 'ARCHIVED' ? <><DropdownMenuSeparator /><DropdownMenuItem data-analytics-id="skill.unarchive.request" onSelect={() => setUnarchiveTarget({ namespace: skill.namespace, slug: skill.slug, name: displayName })}>
<RotateCcw aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.unarchive')}
</DropdownMenuItem></> : publishedVersion ? <><DropdownMenuSeparator /><DropdownMenuItem data-analytics-id="skill.archive.request" className="text-destructive focus:text-destructive" onSelect={() => setArchiveTarget({ namespace: skill.namespace, slug: skill.slug, name: displayName })}>
</DropdownMenuItem></> : publishedVersion ? <><DropdownMenuSeparator /><DropdownMenuItem data-analytics-id="skill.archive.request" className={styles.dangerAction} onSelect={() => setArchiveTarget({ namespace: skill.namespace, slug: skill.slug, name: displayName })}>
<Archive aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.archive')}
</DropdownMenuItem></> : null)}
{canRestoreHidden && <><DropdownMenuSeparator /><DropdownMenuItem data-analytics-id="skill.restore.request" onSelect={() => setRestoreHiddenTarget({ skillId: skill.id, name: displayName })}>
@ -524,18 +532,23 @@ export function MySkillsPage() {
</DropdownMenu>
</div>
</div>
{summary && <p className="line-clamp-2 break-words text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">{summary}</p>}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="min-w-0 break-all">{coordinate}</span>
<span>{t('skillVisibility.current', { visibility: t(`skillVisibility.${skill.visibility ?? 'PUBLIC'}`) })}</span>
{preview && publishedVersion ? <span>{t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })}</span>
{summary && <p className={`${styles.description} line-clamp-2 break-words text-muted-foreground [overflow-wrap:anywhere]`}>{summary}</p>}
<div className={`${styles.metadata} flex flex-wrap items-center text-muted-foreground`}>
<span className={`${styles.metadataItem} ${styles.coordinate}`} title={coordinate}><Package aria-hidden="true" strokeWidth={1.65} className="h-[13px] w-[13px] shrink-0" /><span className="truncate">{coordinate}</span></span>
<span className={styles.metadataItem} title={t('skillVisibility.current', { visibility: t(`skillVisibility.${skill.visibility ?? 'PUBLIC'}`) })}>
{skill.visibility === 'PRIVATE' ? <LockKeyhole aria-hidden="true" strokeWidth={1.65} className="h-[13px] w-[13px] shrink-0" />
: skill.visibility === 'NAMESPACE_ONLY' ? <Users aria-hidden="true" strokeWidth={1.65} className="h-[13px] w-[13px] shrink-0" />
: <Globe aria-hidden="true" strokeWidth={1.65} className="h-[13px] w-[13px] shrink-0" />}
{t(`publish.visibilityOptions.${skill.visibility === 'PRIVATE' ? 'private' : skill.visibility === 'NAMESPACE_ONLY' ? 'namespaceOnly' : 'public'}`)}
</span>
{preview && publishedVersion ? <span className={styles.pendingVersion}>{t('mySkills.versionDifference', { version: preview.version, published: publishedVersion.version })}</span>
: headlineVersion ? <span className="font-mono">v{headlineVersion.version}</span> : null}
<span className="inline-flex items-center gap-1" title={t('mySkills.downloadCount')}><Download aria-hidden="true" className="h-4 w-4" />{formatCompactCount(skill.downloadCount)}</span>
<span className={styles.metadataItem} title={t('mySkills.downloadCount')}><Download aria-hidden="true" strokeWidth={1.65} className="h-[13px] w-[13px] shrink-0" />{formatCompactCount(skill.downloadCount)}</span>
</div>
{ownerReadOnly && <p role="status" className="text-sm text-muted-foreground">{t('mySkills.ownerReadOnly')}</p>}
{skill.managementState?.promotionRole && <p className="text-sm text-muted-foreground">{t(`mySkills.promotionRoles.${skill.managementState.promotionRole}`)}</p>}
{ownerReadOnly && <p role="status" className={`${styles.note} text-muted-foreground`}>{t('mySkills.ownerReadOnly')}</p>}
{skill.managementState?.promotionRole && <p className={`${styles.note} text-muted-foreground`}>{t(`mySkills.promotionRoles.${skill.managementState.promotionRole}`)}</p>}
{skill.status === 'ACTIVE' && !hidden && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
{skill.managementState?.requestedVisibility && <p role="status" className="text-sm text-muted-foreground">
{skill.managementState?.requestedVisibility && <p role="status" className={`${styles.note} text-muted-foreground`}>
{t('skillVisibility.pendingList', { visibility: t(`skillVisibility.${skill.managementState.requestedVisibility}`) })}
</p>}
</div>