visionA 雲端版前端 — 沿用 local-tool 既有 UI(原則 4:先抄 local-tool)+
新增雲端特有的登入 / 配對 / 設定流程,含以下整合階段:
- Phase 0:13 頁 + 30+ 元件 + 雛形 banner
- dashboard / devices / models / workspace / clusters / settings 等頁
- AppShell + Sidebar + Header + tokens + i18n(中英雙語 96 keys)
- API client + 5 stores + 3 hooks
- Phase 0.6:OIDC redirect 改造
- login 頁改為 OIDC redirect(`window.location.href = /api/auth/login`)
- register 改說明頁、account 改唯讀(user 資料來源是 MC)
- api client 改 cookie session(credentials: include)+ 完全清掉 localStorage
- Phase 0.7:stage 部署 + nil guard
- getApiBaseUrl() 修:browser 環境視為 same-origin(與 login 頁一致)
- login 頁加「已登入 → router.replace('/')」effect
- User type email/name 改 optional(MC id_token 不一定回 email/name claim)
- header.tsx UserMenu displayName 4 層 fallback:name → email → id → i18n
- 雛形 banner 文案更新(已接 Innovedus 帳號中心)+ 版號 Phase 0.7
驗證:pnpm lint / test (125/125) / build 全綠
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Sidebar — 左側導航
|
||
*
|
||
* 規格來源:
|
||
* - .autoflow/03-design/components.md §3.1(雲端版 Sidebar 變更)
|
||
* - .autoflow/03-design/pages.md(頁面總覽)
|
||
*
|
||
* 設計重點:
|
||
* - 使用 Lucide Icon(對齊 design-review.md C1 建議,取代 local-tool 的單字母佔位)
|
||
* - 當前路徑用 usePathname() 自動偵測('/' 嚴格比對、其他用 startsWith)
|
||
* - active state 採用 `bg-sidebar-accent text-sidebar-accent-foreground`
|
||
* — 利用既有的 sidebar-* Design Tokens(globals.css 已定義 Light/Dark 兩套)
|
||
* - Desktop 固定寬度 w-60;Mobile 先隱藏(AppShell 負責),未來 F 任務再補 drawer
|
||
* - 版本號顯示於底部(暫以 package.json version 等效字串)
|
||
*
|
||
* 未做(保留給後續任務):
|
||
* - Mobile drawer / Sheet(F6 之後視需要)
|
||
* - UserMenu — 依 F4 任務規格已改放到 Header 右側(不在 Sidebar 底部)
|
||
*/
|
||
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
import {
|
||
Boxes,
|
||
Cable,
|
||
LayoutDashboard,
|
||
Network,
|
||
Play,
|
||
Settings,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
|
||
import { useT, type TranslateFn } from "@/lib/i18n/context";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface NavItem {
|
||
href: string;
|
||
/** i18n key,由 t() 展開 */
|
||
labelKey: string;
|
||
icon: LucideIcon;
|
||
}
|
||
|
||
/**
|
||
* 導航項目 — 對齊 pages.md 頁面總覽與 components.md §3.1 的 icon 指定。
|
||
* 以函式產生讓 test 也能以同一份設定做 assertion(若需要)。
|
||
*/
|
||
const NAV_ITEMS: readonly NavItem[] = [
|
||
{ href: "/", labelKey: "nav.dashboard", icon: LayoutDashboard },
|
||
{ href: "/devices", labelKey: "nav.devices", icon: Cable },
|
||
{ href: "/models", labelKey: "nav.models", icon: Boxes },
|
||
{ href: "/workspace", labelKey: "nav.workspace", icon: Play },
|
||
{ href: "/clusters", labelKey: "nav.clusters", icon: Network },
|
||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||
] as const;
|
||
|
||
/**
|
||
* 是否為當前 active 導航項目。
|
||
* 匯出給 test 使用,避免在測試中重造 active 判定邏輯。
|
||
*/
|
||
export function isNavActive(itemHref: string, pathname: string): boolean {
|
||
if (itemHref === "/") return pathname === "/";
|
||
// startsWith 比對,使得 /devices/pair 也能讓 /devices 維持 active 狀態
|
||
return pathname === itemHref || pathname.startsWith(`${itemHref}/`);
|
||
}
|
||
|
||
interface SidebarProps {
|
||
className?: string;
|
||
}
|
||
|
||
export function Sidebar({ className }: SidebarProps) {
|
||
const pathname = usePathname();
|
||
const t = useT();
|
||
|
||
return (
|
||
<aside
|
||
data-slot="sidebar"
|
||
// Mobile 隱藏;Tablet (sm) 以上顯示
|
||
// 背景 / 邊框用 sidebar-* tokens,天生支援 Dark Mode
|
||
className={cn(
|
||
"hidden h-full w-60 flex-col border-r sm:flex",
|
||
"bg-sidebar text-sidebar-foreground",
|
||
className,
|
||
)}
|
||
>
|
||
{/* 品牌區 — 與 Header 等高 (h-14),視覺水平對齊 */}
|
||
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||
<Link
|
||
href="/"
|
||
className="flex items-center gap-2 font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||
aria-label={t("app.title")}
|
||
>
|
||
{/* Logo 暫以純色方塊占位 — 後續可替換為 <Image /> 或 SVG */}
|
||
<span
|
||
aria-hidden="true"
|
||
className="bg-sidebar-primary text-sidebar-primary-foreground grid size-8 place-items-center rounded-md text-xs font-bold"
|
||
>
|
||
vA
|
||
</span>
|
||
<span className="truncate text-base">{t("app.title")}</span>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* 導航清單 */}
|
||
<nav
|
||
aria-label={t("app.title")}
|
||
className="flex-1 space-y-1 overflow-y-auto p-3"
|
||
>
|
||
{NAV_ITEMS.map((item) => (
|
||
<NavItemLink
|
||
key={item.href}
|
||
item={item}
|
||
active={isNavActive(item.href, pathname)}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</nav>
|
||
|
||
{/* 版本號 — F4 雛形以固定字串,Phase 1 可接 package.json
|
||
Phase 0.7 stage deployment fix(見 .autoflow/05-implementation/phase-0.7-frontend-fix.md):
|
||
將 phase 標識從 "Phase 0" 升到 "Phase 0.7",反映 OIDC 已接入 Member Center */}
|
||
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||
v0.1.0 · Phase 0.7
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
interface NavItemLinkProps {
|
||
item: NavItem;
|
||
active: boolean;
|
||
t: TranslateFn;
|
||
}
|
||
|
||
function NavItemLink({ item, active, t }: NavItemLinkProps) {
|
||
const Icon = item.icon;
|
||
const label = t(item.labelKey);
|
||
|
||
return (
|
||
<Link
|
||
href={item.href}
|
||
data-active={active || undefined}
|
||
aria-current={active ? "page" : undefined}
|
||
className={cn(
|
||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||
"outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||
active
|
||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||
)}
|
||
>
|
||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||
<span className="truncate">{label}</span>
|
||
</Link>
|
||
);
|
||
}
|