All checks were successful
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 19s
CD Pipeline / build-and-deploy (push) Successful in 3m40s
CD Pipeline / post-deploy-checks (push) Successful in 1m20s
284 lines
9.5 KiB
TypeScript
284 lines
9.5 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Sidebar - Operator workflow navigation
|
|
* ======================================
|
|
* Nothing.tech 視覺憲法:
|
|
* - 純白背景 (bg-white)
|
|
* - 極細右邊框 (border-r-[0.5px] border-neutral-200)
|
|
* - 無陰影
|
|
* - 單色圖示
|
|
*
|
|
* IA D0 (2026-06-27):
|
|
* - 側欄保留 primary 工作台與 secondary 深連結。
|
|
* - Runs / Approvals / Contracts / Alerts 等流程細節以二級樣式顯示,
|
|
* 避免路由被移除後只剩隱藏連結。
|
|
* - 頁面內仍以同頁 sections 呈現關鍵狀態,減少二層分頁藏內容。
|
|
*
|
|
* Phase 19: 使用 Z_INDEX.SIDEBAR (40)
|
|
* @see lib/constants/z-index.ts
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { Z_INDEX } from '@/lib/constants/z-index'
|
|
// Phase 8.0 #15: SSE 驅動的待簽核數量
|
|
import { useTranslations } from 'next-intl'
|
|
import { usePathname } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from 'lucide-react'
|
|
import {
|
|
PRODUCT_BOTTOM_NAV_ITEMS,
|
|
PRODUCT_NAV_SECTIONS,
|
|
type ProductNavItem,
|
|
} from '@/lib/navigation/product-ia'
|
|
// Phase 8.0 #15: 改用 approval store SSE (移除 polling)
|
|
import { useApprovalStore } from '@/stores/approval.store'
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface SidebarProps {
|
|
locale: string
|
|
collapsed?: boolean
|
|
compact?: boolean
|
|
width?: number | string
|
|
showToggle?: boolean
|
|
onToggle?: () => void
|
|
onNavigate?: () => void
|
|
className?: string
|
|
}
|
|
|
|
// ============================================================
|
|
// 2026-06-26: Operator-first IA repair
|
|
// ============================================================
|
|
// 參考 Material / Atlassian / Carbon 的 app shell 模式:
|
|
// 側欄放高頻任務與產品導航,頁面內不再重複用二層分頁藏入口。
|
|
// ============================================================
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export function Sidebar({
|
|
locale,
|
|
collapsed = false,
|
|
compact = false,
|
|
width,
|
|
showToggle = true,
|
|
onToggle,
|
|
onNavigate,
|
|
className,
|
|
}: SidebarProps) {
|
|
const t = useTranslations('nav')
|
|
const tSection = useTranslations('navSection')
|
|
const tSidebar = useTranslations('sidebar')
|
|
const pathname = usePathname()
|
|
|
|
// Phase 8.0 #15: 改用 SSE 驅動的 pending count (移除 30s polling)
|
|
// 防止 SSR hydration mismatch: 只在 client 顯示 badge
|
|
const [mounted, setMounted] = useState(false)
|
|
const pendingApprovals = useApprovalStore(state => state.pendingApprovals)
|
|
const pendingCount = pendingApprovals.length
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
const isRouteActive = (href: string, exact = false) => {
|
|
const fullHref = `/${locale}${href === '/' ? '' : href}`
|
|
if (href === '/') {
|
|
return pathname === `/${locale}` || pathname === `/${locale}/`
|
|
}
|
|
if (exact) {
|
|
return pathname === fullHref || pathname === `${fullHref}/`
|
|
}
|
|
return pathname === fullHref || pathname.startsWith(fullHref + '/')
|
|
}
|
|
|
|
const isActive = (item: ProductNavItem) => (
|
|
isRouteActive(item.href, item.exact) ||
|
|
item.aliases?.some(alias => isRouteActive(alias)) === true ||
|
|
item.relatedPaths?.some(path => isRouteActive(path)) === true
|
|
)
|
|
const sidebarWidth = width ?? (compact && collapsed ? 48 : collapsed ? 64 : 224)
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
'fixed left-0 flex-col',
|
|
className ?? 'flex'
|
|
)}
|
|
style={{
|
|
top: 68,
|
|
bottom: 0,
|
|
zIndex: Z_INDEX.SIDEBAR,
|
|
background: '#faf9f3',
|
|
borderRight: '0.5px solid #e0ddd4',
|
|
width: sidebarWidth,
|
|
transition: 'width 0.3s ease',
|
|
}}
|
|
>
|
|
|
|
{/* 導航列表 - Operator workflow sections */}
|
|
<nav className="flex-1 py-2 overflow-y-auto">
|
|
{PRODUCT_NAV_SECTIONS.map(section => {
|
|
if (section.items.length === 0) return null
|
|
|
|
return (
|
|
<div key={section.sectionKey} style={{ marginBottom: 4 }}>
|
|
{!collapsed && (
|
|
<div style={{
|
|
fontSize: 10,
|
|
color: '#b0ad9f',
|
|
letterSpacing: '1.5px',
|
|
textTransform: 'uppercase' as const,
|
|
padding: '12px 12px 3px',
|
|
fontFamily: 'var(--font-body), monospace',
|
|
borderTop: '0.5px solid #e0ddd4',
|
|
marginTop: 4,
|
|
}}>
|
|
{tSection(section.sectionKey)}
|
|
</div>
|
|
)}
|
|
{section.items.map(item => {
|
|
const active = isActive(item)
|
|
const count = item.badge && mounted ? pendingCount : 0
|
|
const secondary = item.surface === 'secondary'
|
|
return (
|
|
<Link
|
|
key={item.id}
|
|
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
|
title={collapsed ? t(item.labelKey) : undefined}
|
|
onClick={onNavigate}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: collapsed ? 0 : secondary ? 7 : 8,
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
padding: collapsed ? '8px 0' : secondary ? '4px 12px 4px 30px' : '6px 12px',
|
|
fontSize: secondary ? 13 : 14,
|
|
color: active ? '#141413' : secondary ? '#77736a' : '#87867f',
|
|
fontWeight: active ? 600 : 400,
|
|
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
|
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
|
textDecoration: 'none',
|
|
transition: 'all 0.15s',
|
|
position: 'relative' as const,
|
|
minHeight: collapsed ? 36 : secondary ? 28 : 32,
|
|
}}
|
|
>
|
|
<item.Icon size={secondary ? 14 : 15} aria-hidden="true" />
|
|
{!collapsed && (
|
|
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
|
{t(item.labelKey)}
|
|
</span>
|
|
)}
|
|
{item.badge && count > 0 && (
|
|
<span style={{
|
|
marginLeft: 'auto',
|
|
fontSize: 12,
|
|
background: '#d97757',
|
|
color: 'white',
|
|
borderRadius: 10,
|
|
padding: '1px 5px',
|
|
fontWeight: 700,
|
|
}}>
|
|
{count > 99 ? '99+' : count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Separator */}
|
|
<div style={{ borderTop: '0.5px solid #e0ddd4', margin: '6px 0' }} />
|
|
|
|
{/* 底部菜單 */}
|
|
{PRODUCT_BOTTOM_NAV_ITEMS.map(item => {
|
|
const active = isActive(item)
|
|
return (
|
|
<Link
|
|
key={item.id}
|
|
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
|
title={collapsed ? t(item.labelKey) : undefined}
|
|
onClick={onNavigate}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: collapsed ? 0 : 8,
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
padding: collapsed ? '7px 0' : '5px 12px',
|
|
fontSize: 13,
|
|
color: active ? '#141413' : '#87867f',
|
|
fontWeight: active ? 600 : 400,
|
|
borderRight: active ? '2px solid #d97757' : '2px solid transparent',
|
|
background: active ? 'rgba(217,119,87,0.08)' : 'transparent',
|
|
textDecoration: 'none',
|
|
transition: 'all 0.15s',
|
|
minHeight: collapsed ? 34 : 30,
|
|
}}
|
|
>
|
|
<item.Icon size={15} aria-hidden="true" />
|
|
{!collapsed && (
|
|
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
|
|
{t(item.labelKey)}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* 底部區域 - 版本號 */}
|
|
<div style={{
|
|
borderTop: '0.5px solid #e0ddd4',
|
|
padding: collapsed ? '8px 0' : '8px 12px',
|
|
textAlign: collapsed ? 'center' : 'left',
|
|
}}>
|
|
{!collapsed && (
|
|
<span style={{ fontSize: 10, color: '#b0ad9f', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
|
v1.0.0
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 折疊按鈕 */}
|
|
{showToggle && (
|
|
<button
|
|
onClick={onToggle}
|
|
style={{
|
|
position: 'absolute',
|
|
right: -12,
|
|
top: 80,
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: '50%',
|
|
background: '#faf9f3',
|
|
border: '0.5px solid #e0ddd4',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
|
|
>
|
|
{collapsed ? (
|
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
|
) : (
|
|
<ChevronLeft className="w-3 h-3 text-neutral-400" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|