From 67e3c8ff195f2d1558586531d12ba3e1d69e7417 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 17 Mar 2026 17:03:55 +0800 Subject: [PATCH 1/3] feat: Implement initial web application structure, core pages, and UI components with i18n support. --- .gitignore | 5 + web/index.html | 2 +- web/src/app/layout.tsx | 248 +++++---- web/src/app/router.tsx | 8 + web/src/features/publish/upload-zone.tsx | 7 +- web/src/features/skill/skill-card.tsx | 10 +- web/src/i18n/locales/en.json | 10 +- web/src/i18n/locales/zh.json | 10 +- web/src/index.css | 188 ++++++- web/src/pages/dashboard.tsx | 6 +- web/src/pages/dashboard/my-skills.tsx | 38 +- web/src/pages/home.tsx | 174 +----- web/src/pages/landing.tsx | 522 +++++------------- web/src/pages/skill-detail.tsx | 4 +- web/src/shared/components/quick-start.tsx | 217 ++++++++ web/src/shared/components/skeleton-loader.tsx | 2 +- web/src/shared/ui/button.tsx | 2 +- web/src/shared/ui/card.tsx | 3 +- web/src/shared/ui/input.tsx | 5 +- web/src/shared/ui/tabs.tsx | 10 +- web/tailwind.config.ts | 6 +- 21 files changed, 734 insertions(+), 743 deletions(-) create mode 100644 web/src/shared/components/quick-start.tsx diff --git a/.gitignore b/.gitignore index f854fd8d..c3d2dc4b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,8 @@ __pycache__/ docs/superpowers/ docs/review/ CLAUDE.md + +# oh-my-claudecode +.omc + + diff --git a/web/index.html b/web/index.html index 107e37f9..99024a11 100644 --- a/web/index.html +++ b/web/index.html @@ -11,7 +11,7 @@ - +
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index c7e1cbef..20e2bf8c 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,68 +2,91 @@ import { Suspense } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' -import { LandingPage } from '@/pages/landing' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { UserMenu } from '@/shared/components/user-menu' export function Layout() { const { t } = useTranslation() const pathname = useRouterState({ select: (s) => s.location.pathname }) - const isLanding = pathname === '/' - const { user, isLoading } = useAuth(!isLanding) + const { user, isLoading } = useAuth() - if (isLanding) { - return + const navItems: Array<{ + label: string + to: string + exact?: boolean + auth?: boolean + }> = [ + { label: t('nav.landing'), to: '/', exact: true }, + { label: t('nav.home'), to: '/skills' }, + { label: t('nav.search'), to: '/search' }, + { label: t('nav.skillDetail'), to: '/space/demo/example' }, + { label: t('nav.dashboard'), to: '/dashboard', auth: true }, + { label: t('nav.mySkills'), to: '/dashboard/skills', auth: true }, + { label: t('nav.publish'), to: '/dashboard/publish', auth: true }, + ] + + const isActive = (to: string, exact?: boolean) => { + if (exact) return pathname === to + // 精确匹配,避免父路径也被高亮 + return pathname === to } return ( -
-
-
+
+ {/* Decorative gradient orb */} +
-
-
-
- -
- S -
- - SkillHub - + {/* Header */} +
+ + SkillHub + + + + +
+ + {isLoading ? null : user ? ( + + ) : ( + + {t('nav.login')} - - -
- - + )}
-
+ {/* Main content */} +
@@ -77,77 +100,84 @@ export function Layout() {
-
-
-
-
-
-
- S + {/* Footer */} +
+
+
+
+
+
+ S
- SkillHub + SkillHub
-

+

{t('layout.footerDescription')}

- -
-

{t('nav.home')}

-
    -
  • - - {t('nav.home')} - -
  • -
  • - - {t('nav.search')} - -
  • -
  • - - {t('nav.dashboard')} - -
  • -
-
- -
-

{t('footer.resources')}

- +
+
+

+ {t('nav.home')} +

+
    +
  • + + {t('nav.home')} + +
  • +
  • + + {t('nav.search')} + +
  • +
  • + + {t('nav.dashboard')} + +
  • +
+
+
+

+ {t('footer.resources')} +

+ +
- -
-

- {t('footer.copyright')} -

-
- +
+ {t('footer.copyright')} +
+ {t('footer.privacy')} - + | + {t('footer.terms')}
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 8adc18e4..12860cea 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -51,6 +51,7 @@ function createRoleProtectedRouteComponent import('@/pages/landing'), 'LandingPage') const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') @@ -141,6 +142,12 @@ async function requireAuth({ location }: { location: { pathname: string; searchS return { user } } +const landingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: LandingPage, +}) + const skillsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'skills', @@ -340,6 +347,7 @@ const adminAuditLogRoute = createRoute({ }) const routeTree = rootRoute.addChildren([ + landingRoute, skillsRoute, loginRoute, registerRoute, diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index aec52e81..e23a603a 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -32,9 +32,8 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
@@ -43,8 +42,8 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) {
- {/* Hover gradient border effect */} -
- -
+
-

+

{skill.displayName}

diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3a41bc87..edc527fb 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1,9 +1,13 @@ { "nav": { - "home": "Home", + "landing": "Home", + "home": "Skill Center", + "search": "Search", + "skillDetail": "Skill Detail", "explore": "Explore Skills", - "search": "Search Skills", "dashboard": "Dashboard", + "mySkills": "My Skills", + "publish": "Publish", "login": "Login" }, "landing": { @@ -848,7 +852,7 @@ "community": "Community", "privacy": "Privacy Policy", "terms": "Terms of Service", - "copyright": "© 2024 SkillHub. All rights reserved." + "copyright": "© 2026 SkillHub. All rights reserved." }, "publish": { "title": "Publish Skill", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e1080cfa..988e106c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1,9 +1,13 @@ { "nav": { - "home": "首页", + "landing": "首页", + "home": "技能中心", + "search": "搜索", + "skillDetail": "技能详情", "explore": "探索", - "search": "搜索技能", "dashboard": "控制台", + "mySkills": "我的技能", + "publish": "发布", "login": "登录" }, "landing": { @@ -848,7 +852,7 @@ "community": "社区", "privacy": "隐私政策", "terms": "服务条款", - "copyright": "© 2024 SkillHub. 保留所有权利。" + "copyright": "© 2026 SkillHub. 保留所有权利。" }, "publish": { "title": "发布技能", diff --git a/web/src/index.css b/web/src/index.css index bd6e84d5..b99f6a03 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,63 +4,76 @@ @layer base { :root { - /* Aurora Tech — light theme (default) */ - --background: 210 40% 97%; - --foreground: 222 47% 11%; + /* SkillHub — 浅色主题 (indigo-violet brand) */ + --background: 210 20% 98%; + --foreground: 220 26% 14%; --card: 0 0% 100%; - --card-foreground: 222 47% 11%; + --card-foreground: 220 26% 14%; --popover: 0 0% 100%; - --popover-foreground: 222 47% 11%; - /* Cyan primary */ - --primary: 192 80% 42%; + --popover-foreground: 220 26% 14%; + /* Indigo primary (#6A6DFF) */ + --primary: 239 100% 71%; --primary-foreground: 0 0% 100%; /* Surface tones */ - --secondary: 210 30% 93%; - --secondary-foreground: 222 47% 11%; - --muted: 210 25% 92%; - --muted-foreground: 215 16% 42%; - /* Violet accent */ - --accent: 263 60% 58%; + --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%; --destructive-foreground: 0 0% 100%; - --border: 214 20% 88%; - --input: 214 20% 88%; - --ring: 192 80% 42%; + --border: 214 32% 91%; + --input: 214 32% 91%; + --ring: 239 100% 71%; --radius: 0.75rem; /* Extended palette */ - --surface-glass: 210 30% 95%; - --glow-primary: 192 80% 42%; - --glow-accent: 263 60% 58%; + --surface-glass: 210 30% 97%; + --glow-primary: 239 100% 71%; + --glow-accent: 271 100% 68%; --success: 160 60% 45%; --warning: 38 92% 58%; + + /* Brand */ + --brand-start: #6A6DFF; + --brand-end: #B85EFF; + --brand-gradient: linear-gradient(135deg, #6A6DFF 0%, #B85EFF 100%); + + /* Text semantic */ + --text-secondary: 215 19% 35%; + --text-muted: 215 14% 46%; + --text-placeholder: 213 12% 63%; + + /* Border semantic */ + --border-card: 220 26% 94%; } .dark { - /* Aurora Tech — deep navy dark theme */ + /* Dark theme (preserved, not default) */ --background: 222 47% 6%; --foreground: 210 40% 96%; --card: 222 40% 9%; --card-foreground: 210 40% 96%; --popover: 222 40% 9%; --popover-foreground: 210 40% 96%; - --primary: 192 91% 56%; + --primary: 239 100% 75%; --primary-foreground: 222 47% 6%; --secondary: 222 30% 13%; --secondary-foreground: 210 30% 85%; --muted: 222 25% 15%; --muted-foreground: 215 20% 55%; - --accent: 263 70% 70%; + --accent: 271 100% 72%; --accent-foreground: 0 0% 100%; --destructive: 0 72% 55%; --destructive-foreground: 0 0% 100%; --border: 222 20% 18%; --input: 222 20% 18%; - --ring: 192 91% 56%; + --ring: 239 100% 75%; --surface-glass: 222 35% 11%; - --glow-primary: 192 91% 56%; - --glow-accent: 263 70% 70%; + --glow-primary: 239 100% 75%; + --glow-accent: 271 100% 72%; } } @@ -75,7 +88,7 @@ body { @apply bg-background text-foreground antialiased; - font-family: 'IBM Plex Sans', system-ui, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } h1, h2, h3, h4, h5, h6 { @@ -216,21 +229,21 @@ /* ─── Gradient text ─── */ .text-gradient-primary { - background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.7)); + background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--accent))); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .text-gradient-hero { - background: linear-gradient(135deg, hsl(192 80% 38%), hsl(192 80% 42%), hsl(263 60% 52%)); + background: var(--brand-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .dark .text-gradient-hero { - background: linear-gradient(135deg, hsl(192 95% 65%), hsl(192 91% 56%), hsl(263 70% 70%)); + background: linear-gradient(135deg, #8183FF, #C77EFF); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -279,3 +292,120 @@ .focus-ring { @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background; } + +/* ─── Brand gradient utilities ─── */ +.text-brand-gradient { + background: var(--brand-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.bg-brand-gradient { + background: var(--brand-gradient); +} + +/* ─── Handle tag (namespace @ pill) ─── */ +.handle-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: #0369a1; + background: #e0f2fe; + border: 1px solid #7dd3fc; + border-radius: 9999px; +} + +/* ─── Status pills ─── */ +.status-pill { + display: inline-flex; + align-items: center; + margin-left: 5px; + padding: 4px 18px; + font-size: 12px; + font-weight: 500; + border-radius: 9999px; + color: white; +} + +.status-pill--published { background: #16a34a; } +.status-pill--review { background: #ea580c; } +.status-pill--archived { background: #6b7280; } + +/* ─── Role pill ─── */ +.role-pill { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + border-radius: 9999px; + border: 1px solid hsl(var(--border)); + color: hsl(var(--text-secondary)); +} + +/* ─── Soft badges ─── */ +.badge-soft { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; +} + +.badge-soft-green { + background: #dcfce7; + color: #166534; +} + +.badge-soft-blue { + background: #dbeafe; + color: #1d4ed8; +} + +/* ─── Tab active ─── */ +.tab-active { + border-bottom: 2px solid #3b82f6; + color: #1d4ed8; +} + +/* ─── Code block ─── */ +.code-block { + background: #1A202C; + color: #e5e7eb; + border-radius: 12px; +} + +/* ─── Upload zone ─── */ +.upload-zone { + border: 2px dashed hsl(var(--border)); + background: hsl(var(--background)); + transition: border-color 0.2s ease, background 0.2s ease; +} + +.upload-zone:hover { + border-color: var(--brand-start); + background: rgba(106, 109, 255, 0.04); +} + +.upload-zone .upload-zone-icon { + color: hsl(var(--muted-foreground)); + transition: color 0.2s ease; +} + +.upload-zone:hover .upload-zone-icon { + color: var(--brand-start); +} + +/* ─── Feature icon shadow ─── */ +.feature-icon { + box-shadow: 0 14px 30px rgba(106, 109, 255, 0.45); +} + +/* ─── Hero input placeholder ─── */ +.hero-input::placeholder { + color: hsl(var(--text-placeholder)); +} diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 896ec209..4102396b 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -20,8 +20,8 @@ export function DashboardPage() { return (
-

{t('dashboard.title')}

-

+

{t('dashboard.title')}

+

{t('dashboard.subtitle')}

@@ -56,7 +56,7 @@ export function DashboardPage() { {user.platformRoles.map((role: string) => ( {role} diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 9ec97540..08cd98f9 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -59,15 +59,15 @@ export function MySkillsPage() { const resolveStatusClassName = (status?: string) => { if (status === 'ARCHIVED') { - return 'bg-slate-500/10 text-slate-500 border-slate-500/20' + return 'status-pill status-pill--archived' } if (status === 'PENDING_REVIEW') { - return 'bg-amber-500/10 text-amber-500 border-amber-500/20' + return 'status-pill status-pill--review' } if (status === 'PUBLISHED') { - return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + return 'status-pill status-pill--published' } - return 'bg-secondary/60 text-muted-foreground border-border/40' + return 'status-pill' } const handleArchiveSkill = async () => { @@ -201,28 +201,16 @@ export function MySkillsPage() { {skill.summary && (

{skill.summary}

)} -
- @{skill.namespace} - {skill.latestVersion && ( - v{skill.latestVersion} - )} - {skill.status ? ( - - {resolveStatusLabel(skill.status)} - - ) : null} - {skill.latestVersionStatus ? ( - - {resolveStatusLabel(skill.latestVersionStatus)} - - ) : null} - - - - - {formatCompactCount(skill.downloadCount)} + {skill.status ? ( + + {resolveStatusLabel(skill.status)} -
+ ) : null} + {skill.latestVersionStatus ? ( + + {resolveStatusLabel(skill.latestVersionStatus)} + + ) : null}
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? ( diff --git a/web/src/pages/home.tsx b/web/src/pages/home.tsx index 3b1ef4c9..ec79abb3 100644 --- a/web/src/pages/home.tsx +++ b/web/src/pages/home.tsx @@ -3,147 +3,10 @@ import { useTranslation } from 'react-i18next' import { SearchBar } from '@/features/search/search-bar' import { SkillCard } from '@/features/skill/skill-card' 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 { Button } from '@/shared/ui/button' -import { Check, Copy, Terminal, Settings, PackageOpen } from 'lucide-react' -import { useState, useMemo } from 'react' - -function getAppBaseUrl(): string { - if (typeof window === 'undefined') { - return 'https://skill.xfyun.cn' - } - const runtimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__ - if (runtimeConfig?.appBaseUrl) { - return runtimeConfig.appBaseUrl - } - return `${window.location.protocol}//${window.location.host}` -} - -function CopyButton({ text }: { text: string }) { - const { t } = useTranslation() - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy:', err) - } - } - - return ( - - ) -} - -function CodeBlock({ code }: { code: string }) { - return ( -
-
- bash - -
-
-        {code}
-      
-
- ) -} - -function QuickStartSection() { - const { t } = useTranslation() - const baseUrl = useMemo(() => getAppBaseUrl(), []) - - const steps = [ - { - icon: , - title: t('home.quickStart.steps.configureEnv.title'), - description: t('home.quickStart.steps.configureEnv.description'), - code: `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} - -# Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'`, - }, - { - icon: , - title: t('home.quickStart.steps.installSkills.title'), - description: t('home.quickStart.steps.installSkills.description'), - code: t('home.quickStart.steps.installSkills.code'), - }, - { - icon: , - title: t('home.quickStart.steps.publishSkills.title'), - description: t('home.quickStart.steps.publishSkills.description'), - code: t('home.quickStart.steps.publishSkills.code'), - }, - ] - - return ( -
-
-

- {t('home.quickStart.title')} - - {t('home.quickStart.subtitle')} - -

-

- {t('home.quickStart.description')} -

-
- -
- {steps.map((step, idx) => ( -
-
-
- {step.icon} -
-
-
-

- {step.title} -

-

- {step.description} -

-
- -
-
-
- ))} -
- -
-
- - {t('home.quickStart.tip')} - -
-
-
- ) -} export function HomePage() { const { t } = useTranslation() @@ -172,13 +35,13 @@ export function HomePage() { {/* Hero Section */}
-

+

SkillHub

-

+

{t('home.subtitle')}

-

+

{t('home.description')}

@@ -188,12 +51,19 @@ export function HomePage() {
- - + +
@@ -201,8 +71,10 @@ export function HomePage() {
-

{t('home.popularTitle')}

-

{t('home.popularDescription')}

+

+ {t('home.popularTitle')} +

+

{t('home.popularDescription')}

) } diff --git a/web/src/pages/landing.tsx b/web/src/pages/landing.tsx index adc5b89f..8dca53fb 100644 --- a/web/src/pages/landing.tsx +++ b/web/src/pages/landing.tsx @@ -1,444 +1,174 @@ import { Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' -import { SearchBar } from '@/features/search/search-bar' -import { useAuth } from '@/features/auth/use-auth' -import { LanguageSwitcher } from '@/shared/components/language-switcher' import { normalizeSearchQuery } from '@/shared/lib/search-query' -import { UserMenu } from '@/shared/components/user-menu' -import { Button } from '@/shared/ui/button' -import { Check, Copy, Terminal, Settings, PackageOpen } from 'lucide-react' -import { useEffect, useRef, useState, useMemo } from 'react' - -function getAppBaseUrl(): string { - if (typeof window === 'undefined') { - return 'https://skill.xfyun.cn' - } - const runtimeConfig = window.__SKILLHUB_RUNTIME_CONFIG__ - if (runtimeConfig?.appBaseUrl) { - return runtimeConfig.appBaseUrl - } - return `${window.location.protocol}//${window.location.host}` -} - -function CopyButton({ text }: { text: string }) { - const { t } = useTranslation() - const [copied, setCopied] = useState(false) - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text) - setCopied(true) - window.setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy:', err) - } - } - - return ( - - ) -} - -function CodeBlock({ code }: { code: string }) { - return ( -
-
- bash - -
-
-        {code}
-      
-
- ) -} - -function QuickStartSection() { - const { t } = useTranslation() - const baseUrl = useMemo(() => getAppBaseUrl(), []) - - const steps = [ - { - icon: , - title: t('landing.quickStart.steps.configureEnv.title'), - description: t('landing.quickStart.steps.configureEnv.description'), - code: `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} - -# Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'`, - }, - { - icon: , - title: t('landing.quickStart.steps.installSkills.title'), - description: t('landing.quickStart.steps.installSkills.description'), - code: t('landing.quickStart.steps.installSkills.code'), - }, - { - icon: , - title: t('landing.quickStart.steps.publishSkills.title'), - description: t('landing.quickStart.steps.publishSkills.description'), - code: t('landing.quickStart.steps.publishSkills.code'), - }, - ] - - return ( -
-
-

- {t('landing.quickStart.title')} - - {t('landing.quickStart.subtitle')} - -

-

- {t('landing.quickStart.description')} -

-
- -
- {steps.map((step, idx) => ( -
-
-
- {step.icon} -
-
-
-

- {step.title} -

-

- {step.description} -

-
- -
-
-
- ))} -
- -
-
- - {t('landing.quickStart.tip')} - -
-
-
- ) -} +import { PackageOpen, Terminal, Shield, Users, GitBranch, Search as SearchIcon, Settings } from 'lucide-react' +import { QuickStartSection } from '@/shared/components/quick-start' export function LandingPage() { const { t } = useTranslation() const navigate = useNavigate() - const { user, isLoading } = useAuth() - const canvasRef = useRef(null) - const [stats] = useState({ - skills: '1000+', - downloads: '50K+', - teams: '200+', - }) - - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const ctx = canvas.getContext('2d') - if (!ctx) return - - const resize = () => { - if (canvas) { - canvas.width = window.innerWidth - canvas.height = window.innerHeight - } - } - resize() - window.addEventListener('resize', resize) - - class Particle { - x: number - y: number - vx: number - vy: number - radius: number - - constructor(canvasWidth: number, canvasHeight: number) { - this.x = Math.random() * canvasWidth - this.y = Math.random() * canvasHeight - this.vx = (Math.random() - 0.5) * 0.3 - this.vy = (Math.random() - 0.5) * 0.3 - this.radius = Math.random() * 1.5 + 0.5 - } - - update(canvasWidth: number, canvasHeight: number) { - this.x += this.vx - this.y += this.vy - if (this.x < 0 || this.x > canvasWidth) this.vx *= -1 - if (this.y < 0 || this.y > canvasHeight) this.vy *= -1 - } - - draw(context: CanvasRenderingContext2D) { - context.beginPath() - context.arc(this.x, this.y, this.radius, 0, Math.PI * 2) - context.fillStyle = 'rgba(56, 189, 248, 0.6)' - context.fill() - } - } - - const particles: Particle[] = [] - const particleCount = 80 - - for (let i = 0; i < particleCount; i++) { - particles.push(new Particle(canvas.width, canvas.height)) - } - - const connectParticles = () => { - for (let i = 0; i < particles.length; i++) { - for (let j = i + 1; j < particles.length; j++) { - const dx = particles[i].x - particles[j].x - const dy = particles[i].y - particles[j].y - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance < 120) { - ctx.beginPath() - const opacity = 0.15 * (1 - distance / 120) - ctx.strokeStyle = 'rgba(56, 189, 248, ' + opacity + ')' - ctx.lineWidth = 0.5 - ctx.moveTo(particles[i].x, particles[i].y) - ctx.lineTo(particles[j].x, particles[j].y) - ctx.stroke() - } - } - } - } - - const animate = () => { - if (!canvas) return - ctx.clearRect(0, 0, canvas.width, canvas.height) - - particles.forEach(particle => { - particle.update(canvas.width, canvas.height) - particle.draw(ctx) - }) - - connectParticles() - requestAnimationFrame(animate) - } - - animate() - - return () => { - window.removeEventListener('resize', resize) - } - }, []) const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: normalizeSearchQuery(query), sort: 'relevance', page: 0, starredOnly: false } }) + const normalized = normalizeSearchQuery(query) + navigate({ + to: '/search', + search: { q: normalized, sort: 'relevance', page: 0, starredOnly: false }, + }) } const features = [ { - icon: '🔒', - title: t('landing.featuresList.privateDeploy.title'), - description: t('landing.featuresList.privateDeploy.description'), + icon: , + title: t('landing.features.secure.title'), + description: t('landing.features.secure.description'), }, { - icon: '📦', - title: t('landing.featuresList.versionControl.title'), - description: t('landing.featuresList.versionControl.description'), + icon: , + title: t('landing.features.community.title'), + description: t('landing.features.community.description'), }, { - icon: '🔍', - title: t('landing.featuresList.smartSearch.title'), - description: t('landing.featuresList.smartSearch.description'), + icon: , + title: t('landing.features.integration.title'), + description: t('landing.features.integration.description'), }, { - icon: '👥', - title: t('landing.featuresList.teamwork.title'), - description: t('landing.featuresList.teamwork.description'), + icon: , + title: t('landing.features.versionControl.title', { defaultValue: '版本控制' }), + description: t('landing.features.versionControl.description', { defaultValue: '完善的版本管理和发布流程,确保技能包的质量和可追溯性。' }), }, { - icon: '✅', - title: t('landing.featuresList.governance.title'), - description: t('landing.featuresList.governance.description'), + icon: , + title: t('landing.features.cli.title', { defaultValue: 'CLI 工具' }), + description: t('landing.features.cli.description', { defaultValue: '强大的命令行工具,支持快速发布、安装和管理技能包。' }), }, { - icon: '⚡', - title: t('landing.featuresList.cliFirst.title'), - description: t('landing.featuresList.cliFirst.description'), + icon: , + title: t('landing.features.governance.title', { defaultValue: '审核治理' }), + description: t('landing.features.governance.description', { defaultValue: '内置审核流程和权限管理,保障企业级技能质量。' }), }, ] + const stats = [ + { value: '1000+', label: t('landing.stats.skills', { defaultValue: '项目库' }) }, + { value: '50K+', label: t('landing.stats.downloads', { defaultValue: '下载量' }) }, + { value: '200+', label: t('landing.stats.teams', { defaultValue: '团队' }) }, + ] + return ( -
-
-
- - SkillHub + <> + {/* Hero Section */} +
+

+ SkillHub +

+

+ {t('landing.hero.title')} +

+

+ {t('landing.hero.subtitle')} +

+ + {/* Search box */} +
+
+ + { + if (e.key === 'Enter') { + handleSearch((e.target as HTMLInputElement).value) + } + }} + /> +
+
+ + {/* CTA buttons */} +
+ + {t('landing.hero.exploreSkills')} + + + {t('landing.hero.publishSkill', { defaultValue: '开始构建' })} -
-
- - -
-
- -
-
-
-
- {t('landing.badge')} -
- -
-

- - SkillHub + {/* Stats */} +
+ {stats.map((stat) => ( +
+ + {stat.value} + + + {stat.label} -

-

- {t('landing.tagline')} - {t('landing.taglineHighlight')} -

-

- {t('landing.description')} -

-
- -
-
-
-
- -
-
- -
- - -
- -
- {Object.entries(stats).map(([key, value]) => ( -
-
- {value} -
-
- {key === 'skills' && t('landing.statsSkills')} - {key === 'downloads' && t('landing.statsDownloads')} - {key === 'teams' && t('landing.statsTeams')} -
-
- ))} -
+ ))}
+
-
-
-

- {t('landing.whyTitle')} SkillHub + {/* Features Section */} +
+
+
+

+ {t('landing.whySkillHub.title', { defaultValue: '为什么选择 SkillHub' })}

-

- {t('landing.whyDescription')} +

+ {t('landing.whySkillHub.subtitle', { defaultValue: '专为企业打造的私有化 Agent 技能管理平台' })}

-
- {features.map((feature, idx) => ( +
+ {features.map((feature) => (
-
-
-
{feature.icon}
-

- {feature.title} -

-

- {feature.description} -

+
+ {feature.icon}
+

+ {feature.title} +

+

+ {feature.description} +

))}
+
- {/* Quick Start Section */} - - -

- -
-
-
-
{t('landing.footerLicense')}
- -
-
-
-
+ {/* Quick Start */} + + ) } diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 9d1582d7..bbfce280 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -461,12 +461,12 @@ export function SkillDetailPage() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} {isPendingPreview && ( - + {t('skillDetail.pendingPreviewBadge')} )} diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx new file mode 100644 index 00000000..7509d3e2 --- /dev/null +++ b/web/src/shared/components/quick-start.tsx @@ -0,0 +1,217 @@ +import { useTranslation } from 'react-i18next' +import { Check, Copy, Settings, Download, Upload } from 'lucide-react' +import { useMemo, useState } from 'react' + +function getAppBaseUrl(): string { + if (typeof window === 'undefined') { + return 'https://skill.xfyun.cn' + } + const runtimeConfig = (window as any).__SKILLHUB_RUNTIME_CONFIG__ + if (runtimeConfig?.appBaseUrl) { + return runtimeConfig.appBaseUrl + } + return `${window.location.protocol}//${window.location.host}` +} + +function CopyButton({ text }: { text: string }) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy:', err) + } + } + + return ( + + ) +} + +function CodeLine({ line }: { line: string }) { + if (line.startsWith('#')) { + return {line} + } + if (line.startsWith('export')) { + return ( + <> + export + {line.slice(6)} + + ) + } + if (line.startsWith('$env:')) { + const eqIdx = line.indexOf('=') + return ( + <> + {line.slice(0, eqIdx).trim()} + {` = ${line.slice(eqIdx + 1).trim()}`} + + ) + } + if (line.startsWith('clawhub')) { + return ( + <> + clawhub + {line.slice(7)} + + ) + } + return {line} +} + +interface CodeBlockProps { + icon: React.ReactNode + iconBg: string + iconColor: string + title: string + description: string + code: string +} + +function CodeBlock({ icon, iconBg, iconColor, title, description, code }: CodeBlockProps) { + return ( +
+
+
+
+ {icon} +
+
+
{title}
+
+ {description} +
+
+
+
+ + + + +
+
+
+ {code.split('\n').map((line, i) => ( +
+ +
+ ))} +
+
+ ) +} + +interface QuickStartProps { + /** 'landing' uses full-width section with centered title; 'page' uses inline layout */ + variant?: 'landing' | 'page' + /** i18n namespace prefix, e.g. 'landing' or 'home' */ + ns?: string +} + +export function QuickStartSection({ variant = 'page', ns = 'landing' }: QuickStartProps) { + const { t } = useTranslation() + const baseUrl = useMemo(() => getAppBaseUrl(), []) + + const envCode = `# Linux/macOS +export CLAWHUB_SITE=${baseUrl} +export CLAWHUB_REGISTRY=${baseUrl} + +# Windows PowerShell +$env:CLAWHUB_SITE = '${baseUrl}' +$env:CLAWHUB_REGISTRY = '${baseUrl}'` + + const installCode = t(`${ns}.quickStart.steps.installSkills.code`, { + defaultValue: '# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install ', + }) + + const publishCode = t(`${ns}.quickStart.steps.publishSkills.code`, { + defaultValue: '# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', + }) + + const steps: CodeBlockProps[] = [ + { + icon: , + iconBg: 'rgba(94,234,212,0.15)', + iconColor: 'var(--code-keyword, #5EEAD4)', + title: t(`${ns}.quickStart.steps.configureEnv.title`), + description: t(`${ns}.quickStart.steps.configureEnv.description`), + code: envCode, + }, + { + icon: , + iconBg: 'rgba(96,165,250,0.15)', + iconColor: '#60A5FA', + title: t(`${ns}.quickStart.steps.installSkills.title`), + description: t(`${ns}.quickStart.steps.installSkills.description`), + code: installCode, + }, + { + icon: , + iconBg: 'rgba(167,139,250,0.15)', + iconColor: '#A78BFA', + title: t(`${ns}.quickStart.steps.publishSkills.title`), + description: t(`${ns}.quickStart.steps.publishSkills.description`), + code: publishCode, + }, + ] + + if (variant === 'landing') { + return ( +
+
+
+

+ {t(`${ns}.quickStart.title`)} +

+

+ Quick Start +

+

+ {t(`${ns}.quickStart.description`, { defaultValue: t(`${ns}.quickStart.subtitle`) })} +

+
+
+ {steps.map((step, idx) => ( + + ))} +
+
+
+ ) + } + + return ( +
+
+

+ {t(`${ns}.quickStart.title`)} +

+

+ {t(`${ns}.quickStart.description`, { defaultValue: t(`${ns}.quickStart.subtitle`) })} +

+
+
+ {steps.map((step, idx) => ( + + ))} +
+
+ ) +} diff --git a/web/src/shared/components/skeleton-loader.tsx b/web/src/shared/components/skeleton-loader.tsx index caf475b2..c6ef3cd2 100644 --- a/web/src/shared/components/skeleton-loader.tsx +++ b/web/src/shared/components/skeleton-loader.tsx @@ -1,6 +1,6 @@ export function SkeletonCard() { return ( -
+
diff --git a/web/src/shared/ui/button.tsx b/web/src/shared/ui/button.tsx index 04701332..3f9d2d7f 100644 --- a/web/src/shared/ui/button.tsx +++ b/web/src/shared/ui/button.tsx @@ -8,7 +8,7 @@ const buttonVariants = cva( variants: { variant: { default: - 'bg-primary text-primary-foreground shadow-glow hover:brightness-110 hover:shadow-glow-lg active:scale-[0.98]', + 'bg-brand-gradient text-white shadow-sm hover:opacity-95 active:scale-[0.98]', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]', outline: diff --git a/web/src/shared/ui/card.tsx b/web/src/shared/ui/card.tsx index cc7e51ec..f434ce4f 100644 --- a/web/src/shared/ui/card.tsx +++ b/web/src/shared/ui/card.tsx @@ -6,9 +6,10 @@ const Card = React.forwardRef ) diff --git a/web/src/shared/ui/input.tsx b/web/src/shared/ui/input.tsx index 1e682d65..e5088693 100644 --- a/web/src/shared/ui/input.tsx +++ b/web/src/shared/ui/input.tsx @@ -4,14 +4,15 @@ import { cn } from '@/shared/lib/utils' export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, type, style, ...props }, ref) => { return ( diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index cea6202a..b4ca1853 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -39,9 +39,10 @@ export function TabsList({ children, className }: TabsListProps) { return (
{children}
@@ -65,12 +66,13 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) { type="button" onClick={() => context.setValue(value)} className={cn( - 'inline-flex items-center justify-center whitespace-nowrap rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap py-3 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', isActive - ? 'bg-card text-foreground shadow-sm border border-border/40' - : 'hover:text-foreground/80 hover:bg-secondary', + ? 'border-b-2 border-primary text-primary' + : 'text-muted-foreground hover:text-foreground/80', className )} + style={{ marginBottom: '-1px' }} > {children} diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 443df8d6..558fc033 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -6,9 +6,9 @@ const config: Config = { theme: { extend: { fontFamily: { - display: ['Playfair Display', 'Georgia', 'serif'], - heading: ['Outfit', 'DM Sans', 'system-ui', 'sans-serif'], - sans: ['DM Sans', 'system-ui', 'sans-serif'], + display: ['Inter', 'system-ui', 'sans-serif'], + heading: ['Inter', 'system-ui', 'sans-serif'], + sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], }, borderRadius: { From 808b80f29581528e153ab90754a2ec469990a304 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 17 Mar 2026 17:07:28 +0800 Subject: [PATCH 2/3] feat: refine landing experience and restore my-skills metadata --- web/src/app/layout.tsx | 1 - web/src/i18n/locales/en.json | 24 ++++++++++++++++++++- web/src/i18n/locales/zh.json | 24 ++++++++++++++++++++- web/src/pages/dashboard/my-skills.tsx | 30 +++++++++++++++++++-------- web/src/pages/skill-detail.tsx | 8 +++---- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 20e2bf8c..85dcb1bf 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -19,7 +19,6 @@ export function Layout() { { label: t('nav.landing'), to: '/', exact: true }, { label: t('nav.home'), to: '/skills' }, { label: t('nav.search'), to: '/search' }, - { label: t('nav.skillDetail'), to: '/space/demo/example' }, { label: t('nav.dashboard'), to: '/dashboard', auth: true }, { label: t('nav.mySkills'), to: '/dashboard/skills', auth: true }, { label: t('nav.publish'), to: '/dashboard/publish', auth: true }, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index edc527fb..283a8b94 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -15,7 +15,8 @@ "title": "Discover & Share AI Skills", "subtitle": "Build powerful AI agents with community-driven skills", "searchPlaceholder": "Search skills...", - "exploreSkills": "Explore Skills" + "exploreSkills": "Explore Skills", + "publishSkill": "Start Building" }, "features": { "secure": { @@ -29,8 +30,29 @@ "integration": { "title": "Easy Integration", "description": "Seamlessly integrate with your existing tools" + }, + "versionControl": { + "title": "Version Control", + "description": "Structured version management and release workflows that keep skill packages traceable and reliable." + }, + "cli": { + "title": "CLI Tooling", + "description": "Powerful command-line workflows for publishing, installing, and managing skill packages." + }, + "governance": { + "title": "Review Governance", + "description": "Built-in review flows and permission controls to keep enterprise skill quality high." } }, + "stats": { + "skills": "Catalogs", + "downloads": "Downloads", + "teams": "Teams" + }, + "whySkillHub": { + "title": "Why SkillHub", + "subtitle": "A private Agent skill platform designed for enterprise teams" + }, "badge": "Enterprise Skill Registry", "tagline": "Publish, Discover, Manage", "taglineHighlight": " Agent Skills", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 988e106c..bc7ec883 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -15,7 +15,8 @@ "title": "发现与分享 AI 技能", "subtitle": "使用社区驱动的技能构建强大的 AI 代理", "searchPlaceholder": "搜索技能...", - "exploreSkills": "探索技能" + "exploreSkills": "探索技能", + "publishSkill": "开始构建" }, "features": { "secure": { @@ -29,8 +30,29 @@ "integration": { "title": "轻松集成", "description": "无缝集成到您现有的工具中" + }, + "versionControl": { + "title": "版本控制", + "description": "完善的版本管理和发布流程,确保技能包的质量和可追溯性。" + }, + "cli": { + "title": "CLI 工具", + "description": "强大的命令行工具,支持快速发布、安装和管理技能包。" + }, + "governance": { + "title": "审核治理", + "description": "内置审核流程和权限管理,保障企业级技能质量。" } }, + "stats": { + "skills": "项目库", + "downloads": "下载量", + "teams": "团队" + }, + "whySkillHub": { + "title": "为什么选择 SkillHub", + "subtitle": "专为企业打造的私有化 Agent 技能管理平台" + }, "badge": "企业级技能注册中心", "tagline": "发布、发现、管理", "taglineHighlight": " Agent 技能包", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 08cd98f9..dcf79616 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -201,16 +201,28 @@ export function MySkillsPage() { {skill.summary && (

{skill.summary}

)} - {skill.status ? ( - - {resolveStatusLabel(skill.status)} +
+ @{skill.namespace} + {skill.latestVersion ? ( + v{skill.latestVersion} + ) : null} + + + + + {formatCompactCount(skill.downloadCount)} - ) : null} - {skill.latestVersionStatus ? ( - - {resolveStatusLabel(skill.latestVersionStatus)} - - ) : null} + {skill.status ? ( + + {resolveStatusLabel(skill.status)} + + ) : null} + {skill.latestVersionStatus ? ( + + {resolveStatusLabel(skill.latestVersionStatus)} + + ) : null} +
{skill.latestVersionStatus === 'PENDING_REVIEW' && skill.latestVersion ? ( diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index bbfce280..09b1bb0e 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -445,9 +445,9 @@ export function SkillDetailPage() { } return ( -
+
{/* Main Content */} -
+
- + {languages.map((lang) => ( changeLanguage(lang.code)} - className={currentLangCode === lang.code ? 'bg-accent' : ''} + className={cn( + 'cursor-pointer rounded-md px-3 py-2', + currentLangCode === lang.code ? 'bg-accent' : '' + )} > {lang.name} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 508e2043..5234e9fa 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -1,15 +1,9 @@ +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import { cn } from '@/shared/lib/utils' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/shared/ui/dropdown-menu' interface User { displayName: string @@ -25,12 +19,48 @@ interface UserMenuProps { export function UserMenu({ user, triggerClassName }: UserMenuProps) { const { t } = useTranslation() const queryClient = useQueryClient() + const rootRef = useRef(null) + const closeTimerRef = useRef(null) + const [isHovered, setIsHovered] = useState(false) + const [isClickOpen, setIsClickOpen] = useState(false) const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN') const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') + const open = isHovered || isClickOpen + + const clearCloseTimer = () => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current) + closeTimerRef.current = null + } + } + + useEffect(() => { + if (!open) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsHovered(false) + setIsClickOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + } + }, [open]) + + useEffect(() => { + return () => { + clearCloseTimer() + } + }, []) const handleLogout = async () => { try { @@ -44,96 +74,120 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { } } + const closeMenu = () => { + clearCloseTimer() + setIsHovered(false) + setIsClickOpen(false) + } + + const handleMouseEnter = () => { + clearCloseTimer() + setIsHovered(true) + } + + const handleMouseLeave = () => { + clearCloseTimer() + closeTimerRef.current = window.setTimeout(() => { + setIsHovered(false) + closeTimerRef.current = null + }, 120) + } + + const menuItemClassName = + 'block w-full rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground' + return ( - - - - - - - - {t('user.menu.dashboard')} - - - - - {t('user.menu.mySkills')} - - - - - {t('user.menu.myNamespaces')} - - - - - {t('user.menu.governance')} - - - - - {t('user.menu.stars')} - - - {isReviewer && ( - - - {t('user.menu.reviews')} - - +
+ + {open ? ( +
+
+ + {t('user.menu.dashboard')} - - )} - {isSkillAdmin && ( - - - {t('user.menu.reports')} + + {t('user.menu.mySkills')} - - )} - {(isUserAdmin || isAuditor) && } - {isUserAdmin && ( - - - {t('user.menu.users')} + + {t('user.menu.myNamespaces')} - - )} - {isAuditor && ( - - - {t('user.menu.auditLog')} + + {t('user.menu.governance')} - - )} - - - - {t('user.menu.security')} - - - - - {t('user.menu.logout')} - - - + + {t('user.menu.stars')} + + {isReviewer ? ( + + {t('user.menu.reviews')} + + ) : null} + {isSkillAdmin ? ( + + {t('user.menu.promotions')} + + ) : null} + {isSkillAdmin ? ( + + {t('user.menu.reports')} + + ) : null} + {isUserAdmin || isAuditor ?
: null} + {isUserAdmin ? ( + + {t('user.menu.users')} + + ) : null} + {isAuditor ? ( + + {t('user.menu.auditLog')} + + ) : null} +
+ + {t('user.menu.security')} + +
+ +
+
+ ) : null} +
) }