feat: refactor navigation and sidebar structure, implement AI Assistant layout modes

This commit is contained in:
Haitao Pan 2026-01-30 18:02:11 +08:00
parent b0fc695e4f
commit e5c616c12a
20 changed files with 2395 additions and 1090 deletions

View File

@ -0,0 +1,160 @@
UI Engineering Principles v1for Code Agents
目标:在不指定框架/库细节的前提下,让 agent 自主做正确的 UI 工程决策。
输出要求:每次改动必须同时满足 可维护 / 可测试 / 可演进 / 可回滚。
1) 先决策再动手Decision Log
在修改前agent 必须在 PR 描述或注释里输出一段 Decision Log最多 10 行):
组件边界如何划分(为什么)
状态放哪(为什么)
哪些是公共骨架,哪些是 slot/children
兼容性策略(是否破坏现有 API
风险点与回滚点
不求长,但求明确。没有 Decision Log 视为不合格提交。
2) 组件设计准则(边界与组合)
2.1 单一职责
一个组件只负责一个“稳定职责”
如果组件名需要用 “And/With/Plus” 连接多个职责 → 必须拆分
2.2 优先 Composition组合而不是 Config配置
优先通过 children / slots 组合 UI 结构
禁止为“结构差异”引入大量 boolean/enum props
只有在“结构稳定但样式/行为轻微差异”时才允许 variant
2.3 公共骨架 + 可插拔区域
抽取稳定的 Layout Skeleton 为 Root/Wrapper
差异点必须落在 children/slotsHeader/Nav/Footer/Actions 等)
3) 状态管理准则(少、近、可注入)
3.1 状态最小化
不新增“可从现有数据推导”的 state用 derived/computed
不新增“只服务渲染”的 state用 CSS/DOM 或 memo
3.2 状态就近原则
UI 局部状态open/collapsed/hover放组件内部
跨多个子组件共享但局部范围内 → Context组件树内
全局业务状态 → store/server仅必要时
3.3 可测试性要求
关键 UI 状态必须可通过 props 注入或通过 context mock
逻辑优先放 hook视图保持“纯 render”
4) 样式与可覆盖性(不锁死)
4.1 className 可组合
所有可复用组件必须接受 className 并合并
禁止写死样式导致无法覆盖(除非明确是私有组件)
4.2 语义优先
使用语义标签nav/aside/header/main/button
可交互元素必须可键盘操作button/link不用 div 冒充)
4.3 选择器稳定
使用 data-* 或有限的 class 作为状态标记(如 collapsed/active
避免复杂层级选择器导致脆弱
5) 兼容性与演进(不破坏用户)
5.1 默认向后兼容
任何改动尽量不破坏旧 APIprops/DOM 结构/路由)
若必须破坏:提供迁移方式或兼容层,并在说明中写清
5.2 小步可回滚
每个提交尽量原子化Atomic Commit
重构必须可分阶段:先抽公共,再迁移,再清理旧代码
6) 性能准则(先可读,后优化)
6.1 不做“预优化”
禁止在没有证据时引入复杂 memo/useCallback/useMemo
若新增 memo必须说明为什么必要避免不必要渲染/昂贵计算)
6.2 可见性能问题再优化
性能优化必须是“局部、可验证、可回滚”的
7) 测试准则(最小但关键)
7.1 测试优先覆盖“行为”,不是“实现”
测试用户可感知行为:导航高亮、折叠、权限可见性等
不测试内部实现细节class 名、组件层级等)
7.2 最小覆盖要求
重构 UI至少补 1 个关键路径测试unit 或 integration
引入新状态/交互:必须有测试覆盖
8) 文档与可读性(未来的你也是用户)
8.1 代码即文档
组件 API 清晰可读prop 名可解释)
复杂逻辑提取为函数/hook并用短注释解释“为什么”
8.2 禁止“聪明代码”
避免迷宫式条件渲染
避免过度抽象(抽象必须带来复用或一致性收益)
9) 输出格式要求agent 必须遵守)
每次提交/变更输出:
Decision Log≤10 行)
变更摘要(改了什么)
风险点(可能破坏什么)
验证方式(如何验证:命令 + 预期)
回滚方式(如何快速退回)
10) 最终验收Self-check
Agent 在结束前必须自检并明确回答(是/否):
是否减少了重复,而不是引入更多分支?
是否让新增需求更容易通过组合实现?
是否状态更少、更近、更可测?
是否可覆盖样式并保持语义化?
是否可回滚(原子提交/分阶段)?
任意一项为“否”,必须解释原因或调整实现。

View File

@ -1,98 +1,126 @@
'use client'
"use client";
import React from 'react'
import { translations } from '../../i18n/translations'
import { useLanguage } from '../../i18n/LanguageProvider'
import Navbar from '../../components/Navbar'
import Footer from '../../components/Footer'
import React from "react";
import { translations } from "../../i18n/translations";
import { useLanguage } from "../../i18n/LanguageProvider";
import UnifiedNavigation from "../../components/UnifiedNavigation";
import Footer from "../../components/Footer";
export default function AboutPage() {
const { language } = useLanguage()
const t = translations[language].about
const { language } = useLanguage();
const t = translations[language].about;
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150">
<div className="absolute inset-0 bg-gradient-app-from opacity-20" aria-hidden />
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150">
<div
className="absolute inset-0 bg-gradient-app-from opacity-20"
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 pb-20">
<Navbar />
<div className="relative mx-auto max-w-7xl px-6 pb-20">
<UnifiedNavigation />
<main className="pt-20 lg:pt-32">
<div className="mx-auto max-w-3xl space-y-12">
{/* Header */}
<div className="space-y-4 text-center">
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-5xl">
{t.title}
</h1>
<p className="text-lg text-text-muted">
{t.subtitle}
</p>
</div>
{/* Disclaimer Section */}
<div className="rounded-2xl border border-warning/20 bg-warning/5 p-8 shadow-inner shadow-warning/10">
<div className="flex gap-4">
<div className="mt-1 shrink-0 text-warning">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-warning-foreground">Disclaimer</h3>
<p className="text-sm leading-relaxed text-warning-foreground/80">
{t.disclaimer}
</p>
</div>
</div>
</div>
{/* Acknowledgments */}
<div className="space-y-8 rounded-3xl border border-surface-border bg-surface p-8 lg:p-12 shadow-2xl backdrop-blur-sm">
<div className="space-y-6">
<p className="text-lg leading-relaxed text-text-muted">
{t.acknowledgments}
</p>
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary">
{t.toolsTitle}
</h3>
<ul className="grid gap-3 sm:grid-cols-2">
{t.tools.map((tool, index) => (
<li key={index} className="flex items-center gap-2 text-sm text-text-muted">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
<a
href={tool.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-text hover:underline hover:decoration-primary"
>
{tool.label}
</a>
</li>
))}
</ul>
<p className="text-xs text-text-subtle">
{t.toolsNote}
</p>
</div>
</div>
<div className="relative overflow-hidden rounded-xl bg-primary/10 p-6">
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 opacity-50" />
<div className="relative flex items-center gap-4 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-heart h-6 w-6"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" /></svg>
<p className="font-medium">
{t.opensource}
</p>
</div>
</div>
</div>
</div>
</main>
<Footer />
<main className="pt-20 lg:pt-32">
<div className="mx-auto max-w-3xl space-y-12">
{/* Header */}
<div className="space-y-4 text-center">
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-5xl">
{t.title}
</h1>
<p className="text-lg text-text-muted">{t.subtitle}</p>
</div>
</div>
)
{/* Disclaimer Section */}
<div className="rounded-2xl border border-warning/20 bg-warning/5 p-8 shadow-inner shadow-warning/10">
<div className="flex gap-4">
<div className="mt-1 shrink-0 text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-alert-triangle"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
</div>
<div className="space-y-2">
<h3 className="font-semibold text-warning-foreground">
Disclaimer
</h3>
<p className="text-sm leading-relaxed text-warning-foreground/80">
{t.disclaimer}
</p>
</div>
</div>
</div>
{/* Acknowledgments */}
<div className="space-y-8 rounded-3xl border border-surface-border bg-surface p-8 lg:p-12 shadow-2xl backdrop-blur-sm">
<div className="space-y-6">
<p className="text-lg leading-relaxed text-text-muted">
{t.acknowledgments}
</p>
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary">
{t.toolsTitle}
</h3>
<ul className="grid gap-3 sm:grid-cols-2">
{t.tools.map((tool, index) => (
<li
key={index}
className="flex items-center gap-2 text-sm text-text-muted"
>
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
<a
href={tool.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-text hover:underline hover:decoration-primary"
>
{tool.label}
</a>
</li>
))}
</ul>
<p className="text-xs text-text-subtle">{t.toolsNote}</p>
</div>
</div>
<div className="relative overflow-hidden rounded-xl bg-primary/10 p-6">
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 opacity-50" />
<div className="relative flex items-center gap-4 text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-heart h-6 w-6"
>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
<p className="font-medium">{t.opensource}</p>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
</div>
);
}

View File

@ -0,0 +1,216 @@
'use client'
import React from 'react'
import { BellRing, Compass, Layers, Sparkles, type LucideIcon, PanelLeftClose, PanelLeftOpen, EyeOff } from 'lucide-react'
import { QueryLanguage, TopologyMode } from '../../insight/store/urlState'
import { SidebarHeader, SidebarContent } from '../../../../components/layout/SidebarRoot'
interface InsightSidebarContentProps {
topologyMode: TopologyMode
activeLanguages: QueryLanguage[]
onSelectSection: (section: string) => void
onTopologyChange: (mode: TopologyMode) => void
onToggleLanguage: (language: QueryLanguage) => void
onToggleCollapse: () => void
onHide: () => void
activeSection: string
collapsed: boolean
}
const sections: { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'topology', label: 'Topology', icon: Layers },
{ id: 'explore', label: 'Explore', icon: Compass },
{ id: 'slo', label: 'SLO & Alerts', icon: BellRing },
{ id: 'ai', label: 'AI Assistant', icon: Sparkles }
]
const topologyOptions: { id: TopologyMode; label: string; hint: string }[] = [
{ id: 'application', label: 'Application', hint: 'Services and dependencies' },
{ id: 'network', label: 'Network', hint: 'Gateways, meshes and edges' },
{ id: 'resource', label: 'Resource', hint: 'Clusters, nodes and workloads' }
]
const languageOptions: { id: QueryLanguage; label: string; description: string }[] = [
{ id: 'promql', label: 'PromQL', description: 'Metrics analytics' },
{ id: 'logql', label: 'LogQL', description: 'Log navigation' },
{ id: 'traceql', label: 'TraceQL', description: 'Trace exploration' }
]
const languageLabels: Record<QueryLanguage, string> = {
promql: 'Prometheus metrics',
logql: 'Log stream',
traceql: 'Distributed traces'
}
export function InsightSidebarContent({
topologyMode,
activeLanguages,
activeSection,
onSelectSection,
onTopologyChange,
onToggleLanguage,
onToggleCollapse,
onHide,
collapsed
}: InsightSidebarContentProps) {
return (
<>
<SidebarHeader className={`flex items-start justify-between mb-7 ${collapsed ? 'flex-col items-center gap-4' : ''}`}>
{!collapsed && (
<div className="space-y-2">
<h1 className="text-lg font-semibold text-slate-100">Insight Workbench</h1>
<p className="text-sm text-slate-400">
Navigate topology, run cross-domain queries and keep SLOs on track.
</p>
</div>
)}
<div className={`flex items-center gap-2 ${collapsed ? '' : 'ml-2'}`}>
<button
type="button"
onClick={onToggleCollapse}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
<button
type="button"
onClick={onHide}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-red-500/60 hover:text-red-300"
aria-label="Hide sidebar"
>
<EyeOff className="h-4 w-4" />
</button>
</div>
</SidebarHeader>
<SidebarContent className={`space-y-7 ${collapsed ? 'flex flex-col items-center gap-2' : ''}`}>
<nav className={`space-y-1 ${collapsed ? 'w-full flex flex-col items-center gap-2 space-y-0' : ''}`}>
{sections.map(section => {
const active = activeSection === section.id
const Icon = section.icon
return (
<div key={section.id} className={`group relative ${collapsed ? 'w-full' : ''}`}>
<button
onClick={() => onSelectSection(section.id)}
className={`w-full rounded-xl transition ${collapsed
? 'flex flex-col items-center gap-2 px-2 py-3'
: 'flex items-center gap-3 px-3 py-2 text-left'
} ${active
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
: 'text-slate-300 hover:bg-slate-800/60'
}`}
title={section.label}
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
}`}
>
<Icon className="h-5 w-5" />
</span>
{!collapsed && <span className="font-medium">{section.label}</span>}
</button>
{section.id === 'topology' && (
<div
className={`pointer-events-none absolute z-20 hidden w-60 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Topology mode</p>
<p className="text-xs text-slate-500">Hover to select how the topology map is rendered.</p>
</div>
<div className="flex flex-col gap-2">
{topologyOptions.map(option => {
const activeMode = topologyMode === option.id
return (
<button
key={option.id}
className={`flex flex-col rounded-xl border px-3 py-2 text-left transition ${activeMode
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onTopologyChange(option.id)}
type="button"
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-xs text-slate-400">{option.hint}</span>
</button>
)
})}
</div>
</div>
</div>
)}
{section.id === 'explore' && (
<div
className={`pointer-events-none absolute z-20 hidden w-64 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Query domains</p>
<p className="text-xs text-slate-500">Toggle languages to open matching explorers.</p>
</div>
<div className="flex flex-col gap-2">
{languageOptions.map(option => {
const activeLanguage = activeLanguages.includes(option.id)
return (
<button
key={option.id}
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition ${activeLanguage
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onToggleLanguage(option.id)}
type="button"
>
<span className="font-medium">{option.label}</span>
<span className="text-xs text-slate-400">{option.description}</span>
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
})}
</nav>
<div
className={`rounded-2xl border border-slate-800 bg-gradient-to-br from-slate-800/80 to-slate-900 shadow-inner ${collapsed ? 'p-3' : 'p-4'
}`}
>
{!collapsed ? (
<>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Active explorers</p>
<ul className="space-y-1 text-sm text-slate-300">
{activeLanguages.map(language => (
<li key={language} className="flex items-center justify-between">
<span>{languageLabels[language]}</span>
<span className="text-xs text-slate-500">QL</span>
</li>
))}
{activeLanguages.length === 0 && <li className="text-xs text-slate-500">No languages selected.</li>}
</ul>
</>
) : (
<div className="flex flex-col items-center gap-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Active</p>
<div className="flex flex-col items-center gap-1 text-[10px] text-slate-300">
{activeLanguages.map(language => (
<span key={language}>{languageLabels[language]}</span>
))}
{activeLanguages.length === 0 && <span className="text-slate-500">None</span>}
</div>
</div>
)}
</div>
</SidebarContent>
</>
)
}

View File

@ -1,8 +1,9 @@
'use client'
import { BellRing, Compass, Layers, Sparkles, type LucideIcon, PanelLeftClose, PanelLeftOpen, EyeOff } from 'lucide-react'
import React from 'react'
import { QueryLanguage, TopologyMode } from '../../insight/store/urlState'
import { SidebarRoot } from '../../../../components/layout/SidebarRoot'
import { InsightSidebarContent } from './InsightSidebarContent'
interface SidebarProps {
topologyMode: TopologyMode
@ -16,210 +17,15 @@ interface SidebarProps {
collapsed: boolean
}
const sections: { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'topology', label: 'Topology', icon: Layers },
{ id: 'explore', label: 'Explore', icon: Compass },
{ id: 'slo', label: 'SLO & Alerts', icon: BellRing },
{ id: 'ai', label: 'AI Assistant', icon: Sparkles }
]
export function Sidebar(props: SidebarProps) {
const { collapsed } = props
const topologyOptions: { id: TopologyMode; label: string; hint: string }[] = [
{ id: 'application', label: 'Application', hint: 'Services and dependencies' },
{ id: 'network', label: 'Network', hint: 'Gateways, meshes and edges' },
{ id: 'resource', label: 'Resource', hint: 'Clusters, nodes and workloads' }
]
const languageOptions: { id: QueryLanguage; label: string; description: string }[] = [
{ id: 'promql', label: 'PromQL', description: 'Metrics analytics' },
{ id: 'logql', label: 'LogQL', description: 'Log navigation' },
{ id: 'traceql', label: 'TraceQL', description: 'Trace exploration' }
]
export function Sidebar({
topologyMode,
activeLanguages,
activeSection,
onSelectSection,
onTopologyChange,
onToggleLanguage,
onToggleCollapse,
onHide,
collapsed
}: SidebarProps) {
return (
<aside
className={`border-r border-slate-800 bg-slate-900/70 px-3 py-6 backdrop-blur transition-all duration-200 ${
collapsed ? 'w-20 space-y-6' : 'w-full space-y-7 lg:w-72 xl:w-80'
}`}
>
<div className={`flex items-start justify-between ${collapsed ? 'flex-col items-center gap-4' : ''}`}>
{!collapsed && (
<div className="space-y-2">
<h1 className="text-lg font-semibold text-slate-100">Insight Workbench</h1>
<p className="text-sm text-slate-400">
Navigate topology, run cross-domain queries and keep SLOs on track.
</p>
</div>
)}
<div className={`flex items-center gap-2 ${collapsed ? '' : 'ml-2'}`}>
<button
type="button"
onClick={onToggleCollapse}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-slate-700 hover:text-slate-100"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
<button
type="button"
onClick={onHide}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-800 bg-slate-900/80 text-slate-300 transition hover:border-red-500/60 hover:text-red-300"
aria-label="Hide sidebar"
>
<EyeOff className="h-4 w-4" />
</button>
</div>
</div>
<nav className={`space-y-1 ${collapsed ? 'flex flex-col items-center gap-2 space-y-0' : ''}`}>
{sections.map(section => {
const active = activeSection === section.id
const Icon = section.icon
return (
<div key={section.id} className={`group relative ${collapsed ? 'w-full' : ''}`}>
<button
onClick={() => onSelectSection(section.id)}
className={`w-full rounded-xl transition ${
collapsed
? 'flex flex-col items-center gap-2 px-2 py-3'
: 'flex items-center gap-3 px-3 py-2 text-left'
} ${
active
? 'bg-slate-800 text-slate-100 shadow-inner shadow-slate-800/60'
: 'text-slate-300 hover:bg-slate-800/60'
}`}
title={section.label}
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl border ${
active ? 'border-slate-700 bg-slate-800 text-slate-100' : 'border-slate-800 bg-slate-900 text-slate-400'
}`}
>
<Icon className="h-5 w-5" />
</span>
{!collapsed && <span className="font-medium">{section.label}</span>}
</button>
{section.id === 'topology' && (
<div
className={`pointer-events-none absolute z-20 hidden w-60 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${
collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Topology mode</p>
<p className="text-xs text-slate-500">Hover to select how the topology map is rendered.</p>
</div>
<div className="flex flex-col gap-2">
{topologyOptions.map(option => {
const activeMode = topologyMode === option.id
return (
<button
key={option.id}
className={`flex flex-col rounded-xl border px-3 py-2 text-left transition ${
activeMode
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onTopologyChange(option.id)}
type="button"
>
<span className="text-sm font-semibold">{option.label}</span>
<span className="text-xs text-slate-400">{option.hint}</span>
</button>
)
})}
</div>
</div>
</div>
)}
{section.id === 'explore' && (
<div
className={`pointer-events-none absolute z-20 hidden w-64 rounded-2xl border border-slate-800 bg-slate-950/90 p-3 text-left shadow-xl backdrop-blur transition group-hover:pointer-events-auto group-hover:flex group-focus-within:pointer-events-auto group-focus-within:flex ${
collapsed ? 'left-full top-1/2 ml-3 -translate-y-1/2' : 'left-full top-1/2 ml-4 -translate-y-1/2'
}`}
>
<div className="flex flex-col gap-2 text-sm text-slate-200">
<div>
<p className="text-xs uppercase tracking-wide text-slate-400">Query domains</p>
<p className="text-xs text-slate-500">Toggle languages to open matching explorers.</p>
</div>
<div className="flex flex-col gap-2">
{languageOptions.map(option => {
const activeLanguage = activeLanguages.includes(option.id)
return (
<button
key={option.id}
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition ${
activeLanguage
? 'border-emerald-500/70 bg-emerald-500/10 text-emerald-200'
: 'border-slate-800 bg-slate-900/70 text-slate-200 hover:border-slate-700'
}`}
onClick={() => onToggleLanguage(option.id)}
type="button"
>
<span className="font-medium">{option.label}</span>
<span className="text-xs text-slate-400">{option.description}</span>
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
})}
</nav>
<div
className={`rounded-2xl border border-slate-800 bg-gradient-to-br from-slate-800/80 to-slate-900 shadow-inner ${
collapsed ? 'p-3' : 'p-4'
<SidebarRoot
className={`border-r border-slate-800 bg-slate-900/70 px-3 py-6 backdrop-blur ${collapsed ? 'w-20' : 'w-full lg:w-72 xl:w-80'
}`}
>
{!collapsed ? (
<>
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Active explorers</p>
<ul className="space-y-1 text-sm text-slate-300">
{activeLanguages.map(language => (
<li key={language} className="flex items-center justify-between">
<span>{languageLabels[language]}</span>
<span className="text-xs text-slate-500">QL</span>
</li>
))}
{activeLanguages.length === 0 && <li className="text-xs text-slate-500">No languages selected.</li>}
</ul>
</>
) : (
<div className="flex flex-col items-center gap-2">
<p className="text-xs uppercase tracking-wide text-slate-400">Active</p>
<div className="flex flex-col items-center gap-1 text-[10px] text-slate-300">
{activeLanguages.map(language => (
<span key={language}>{languageLabels[language]}</span>
))}
{activeLanguages.length === 0 && <span className="text-slate-500">None</span>}
</div>
</div>
)}
</div>
</aside>
>
<InsightSidebarContent {...props} />
</SidebarRoot>
)
}
const languageLabels: Record<QueryLanguage, string> = {
promql: 'Prometheus metrics',
logql: 'Log stream',
traceql: 'Distributed traces'
}

View File

@ -1,235 +1,20 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import {
ChevronRight,
ChevronDown,
Book,
Settings,
Zap,
Shield,
HelpCircle,
Code,
Terminal,
Activity,
GraduationCap,
Layout,
Layers,
Puzzle
} from 'lucide-react'
import type { DocCollection, DocVersionOption } from './types'
import type { DocCollection } from './types'
import { SidebarRoot } from '../../components/layout/SidebarRoot'
import { DocsSidebarContent } from './DocsSidebarContent'
interface DocsSidebarProps {
collections: DocCollection[]
}
// Helper to humanize category names
const humanize = (s: string) => {
if (!s) return ''
return s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
// Icon mapping for categories
const ICON_MAP: Record<string, any> = {
'getting-started': Book,
'architecture': Zap,
'usage': Settings,
'advanced': GraduationCap,
'api': Code,
'development': Terminal,
'operations': Activity,
'governance': Shield,
'appendix': HelpCircle,
'integrations': Puzzle,
'overview': Layout,
'core-concepts': Layers,
}
const ADVANCED_GROUP = ['api', 'development', 'operations', 'governance', 'advanced']
export default function DocsSidebar({ collections }: DocsSidebarProps) {
const pathname = usePathname()
// Sort collections: Console first, then others alphabetically
const sortedCollections = [...collections].sort((a, b) => {
if (a.slug.includes('console')) return -1
if (b.slug.includes('console')) return 1
return a.title.localeCompare(b.title)
})
return (
<aside className="sticky top-[64px] hidden h-[calc(100vh-64px)] w-72 shrink-0 overflow-y-auto border-r border-surface-border bg-background/50 backdrop-blur-sm py-8 pl-8 pr-4 lg:block">
<nav className="space-y-10">
{sortedCollections.map((collection) => (
<CollectionGroup
key={collection.slug}
collection={collection}
activePath={pathname}
/>
))}
</nav>
</aside>
)
}
function CollectionGroup({ collection, activePath }: { collection: DocCollection; activePath: string }) {
const [isOpen, setIsOpen] = useState(true)
// Group versions by category
const grouped: Record<string, DocVersionOption[]> = {}
const topLevel: DocVersionOption[] = []
collection.versions.forEach(v => {
const category = v.category
if (!category || category === 'overview' || category === 'index') {
topLevel.push(v)
} else {
if (!grouped[category]) grouped[category] = []
grouped[category].push(v)
}
})
const hasAdvanced = ADVANCED_GROUP.some(k => grouped[k])
return (
<div className="space-y-4">
<button
onClick={() => setIsOpen(!isOpen)}
className="group flex w-full items-center justify-between text-xs font-bold uppercase tracking-widest text-text-subtle transition-colors hover:text-primary"
>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-primary/40 group-hover:bg-primary transition-colors"></span>
{collection.title}
</span>
{isOpen ? <ChevronDown className="h-3.5 w-3.5 opacity-50" /> : <ChevronRight className="h-3.5 w-3.5 opacity-50" />}
</button>
{isOpen && (
<div className="space-y-6">
{/* Uncategorized / Overview / README */}
{topLevel.length > 0 && (
<ul className="space-y-1">
{topLevel.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collection.slug} activePath={activePath} />
))}
</ul>
)}
{/* Main Categories (Getting Started, Architecture, etc.) */}
<div className="space-y-4">
{Object.entries(grouped)
.filter(([k]) => !ADVANCED_GROUP.includes(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([category, versions]) => (
<CategorySection
key={category}
title={category}
versions={versions}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</div>
{/* Advanced Section Dropdown */}
{hasAdvanced && (
<AdvancedSection
grouped={grouped}
collectionSlug={collection.slug}
activePath={activePath}
/>
)}
</div>
)}
</div>
)
}
function CategorySection({ title, versions, collectionSlug, activePath }: { title: string; versions: DocVersionOption[]; collectionSlug: string; activePath: string }) {
const Icon = ICON_MAP[title] || Book
// Auto-expand if active
const isActive = versions.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`)
const [isExpanded, setIsExpanded] = useState(true)
return (
<div className="space-y-1">
<div
className={`flex w-full items-center gap-2.5 px-2 py-1 text-[11px] font-bold uppercase tracking-tight ${isActive ? 'text-primary' : 'text-text-muted/80'}`}
>
<Icon className={`h-3.5 w-3.5 ${isActive ? 'text-primary' : 'text-text-subtle'}`} />
<span>{humanize(title)}</span>
</div>
<ul className="ml-3.5 space-y-1 border-l border-surface-border pl-4">
{versions.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
)
}
function AdvancedSection({ grouped, collectionSlug, activePath }: { grouped: Record<string, DocVersionOption[]>; collectionSlug: string; activePath: string }) {
// Check if anything inside is active to auto-expand
const isInsideActive = ADVANCED_GROUP.some(k => grouped[k]?.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`))
const [isExpanded, setIsExpanded] = useState(isInsideActive)
useEffect(() => {
if (isInsideActive) setIsExpanded(true)
}, [isInsideActive])
return (
<div className="space-y-2 rounded-lg bg-surface-muted/30 p-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-bold transition-all ${isExpanded ? 'text-primary bg-primary/5' : 'text-text-muted hover:text-primary hover:bg-primary/5'}`}
>
<GraduationCap className={`h-4 w-4 ${isExpanded ? 'text-primary' : 'text-text-subtle'}`} />
<span className="uppercase tracking-wide">Advanced</span>
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform duration-300 ${isExpanded ? '' : '-rotate-90'}`} />
</button>
{isExpanded && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1 duration-300">
{ADVANCED_GROUP.map(k => grouped[k] && (
<div key={k} className="space-y-2 py-1">
<div className="flex items-center gap-2 px-3 text-[10px] font-bold uppercase tracking-widest text-text-subtle/50">
<span className="h-[1px] w-2 bg-surface-border"></span>
{humanize(k)}
</div>
<ul className="ml-4 space-y-1 border-l border-surface-border/50 pl-4">
{grouped[k].map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
))}
</div>
)}
</div>
)
}
function SidebarLink({ version, collectionSlug, activePath }: { version: DocVersionOption; collectionSlug: string; activePath: string }) {
const href = `/docs/${collectionSlug}/${version.slug}`
const isPageActive = activePath === href
return (
<li>
<Link
href={href}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-all duration-200 ${isPageActive
? 'bg-primary/10 text-primary font-medium shadow-sm'
: 'text-text-muted hover:text-heading hover:bg-surface-muted'
}`}
>
{isPageActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
<span className="truncate">{version.title}</span>
{!isPageActive && <ChevronRight className="ml-auto h-3 w-3 opacity-0 transition-all -translate-x-2 group-hover:opacity-30 group-hover:translate-x-0" />}
</Link>
</li>
<SidebarRoot className="sticky top-[64px] hidden h-[calc(100vh-64px)] w-72 shrink-0 border-r border-surface-border bg-background/50 backdrop-blur-sm py-8 pl-8 pr-4 lg:block">
<DocsSidebarContent collections={collections} activePath={pathname} />
</SidebarRoot>
)
}

View File

@ -0,0 +1,233 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import {
ChevronRight,
ChevronDown,
Book,
Settings,
Zap,
Shield,
HelpCircle,
Code,
Terminal,
Activity,
GraduationCap,
Layout,
Layers,
Puzzle
} from 'lucide-react'
import type { DocCollection, DocVersionOption } from './types'
import { SidebarContent } from '../../components/layout/SidebarRoot'
interface DocsSidebarContentProps {
collections: DocCollection[]
activePath: string
}
// Helper to humanize category names
const humanize = (s: string) => {
if (!s) return ''
return s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
// Icon mapping for categories
const ICON_MAP: Record<string, any> = {
'getting-started': Book,
'architecture': Zap,
'usage': Settings,
'advanced': GraduationCap,
'api': Code,
'development': Terminal,
'operations': Activity,
'governance': Shield,
'appendix': HelpCircle,
'integrations': Puzzle,
'overview': Layout,
'core-concepts': Layers,
}
const ADVANCED_GROUP = ['api', 'development', 'operations', 'governance', 'advanced']
export function DocsSidebarContent({ collections, activePath }: DocsSidebarContentProps) {
// Sort collections: Console first, then others alphabetically
const sortedCollections = [...collections].sort((a, b) => {
if (a.slug.includes('console')) return -1
if (b.slug.includes('console')) return 1
return a.title.localeCompare(b.title)
})
return (
<SidebarContent>
<nav className="space-y-10">
{sortedCollections.map((collection) => (
<CollectionGroup
key={collection.slug}
collection={collection}
activePath={activePath}
/>
))}
</nav>
</SidebarContent>
)
}
function CollectionGroup({ collection, activePath }: { collection: DocCollection; activePath: string }) {
const [isOpen, setIsOpen] = useState(true)
// Group versions by category
const grouped: Record<string, DocVersionOption[]> = {}
const topLevel: DocVersionOption[] = []
collection.versions.forEach(v => {
const category = v.category
if (!category || category === 'overview' || category === 'index') {
topLevel.push(v)
} else {
if (!grouped[category]) grouped[category] = []
grouped[category].push(v)
}
})
const hasAdvanced = ADVANCED_GROUP.some(k => grouped[k])
return (
<div className="space-y-4">
<button
onClick={() => setIsOpen(!isOpen)}
className="group flex w-full items-center justify-between text-xs font-bold uppercase tracking-widest text-text-subtle transition-colors hover:text-primary"
>
<span className="flex items-center gap-2">
<span className="h-1 w-1 rounded-full bg-primary/40 group-hover:bg-primary transition-colors"></span>
{collection.title}
</span>
{isOpen ? <ChevronDown className="h-3.5 w-3.5 opacity-50" /> : <ChevronRight className="h-3.5 w-3.5 opacity-50" />}
</button>
{isOpen && (
<div className="space-y-6">
{/* Uncategorized / Overview / README */}
{topLevel.length > 0 && (
<ul className="space-y-1">
{topLevel.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collection.slug} activePath={activePath} />
))}
</ul>
)}
{/* Main Categories (Getting Started, Architecture, etc.) */}
<div className="space-y-4">
{Object.entries(grouped)
.filter(([k]) => !ADVANCED_GROUP.includes(k))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([category, versions]) => (
<CategorySection
key={category}
title={category}
versions={versions}
collectionSlug={collection.slug}
activePath={activePath}
/>
))}
</div>
{/* Advanced Section Dropdown */}
{hasAdvanced && (
<AdvancedSection
grouped={grouped}
collectionSlug={collection.slug}
activePath={activePath}
/>
)}
</div>
)}
</div>
)
}
function CategorySection({ title, versions, collectionSlug, activePath }: { title: string; versions: DocVersionOption[]; collectionSlug: string; activePath: string }) {
const Icon = ICON_MAP[title] || Book
// Auto-expand if active
const isActive = versions.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`)
return (
<div className="space-y-1">
<div
className={`flex w-full items-center gap-2.5 px-2 py-1 text-[11px] font-bold uppercase tracking-tight ${isActive ? 'text-primary' : 'text-text-muted/80'}`}
>
<Icon className={`h-3.5 w-3.5 ${isActive ? 'text-primary' : 'text-text-subtle'}`} />
<span>{humanize(title)}</span>
</div>
<ul className="ml-3.5 space-y-1 border-l border-surface-border pl-4">
{versions.map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
)
}
function AdvancedSection({ grouped, collectionSlug, activePath }: { grouped: Record<string, DocVersionOption[]>; collectionSlug: string; activePath: string }) {
// Check if anything inside is active to auto-expand
const isInsideActive = ADVANCED_GROUP.some(k => grouped[k]?.some(v => activePath === `/docs/${collectionSlug}/${v.slug}`))
const [isExpanded, setIsExpanded] = useState(isInsideActive)
useEffect(() => {
if (isInsideActive) setIsExpanded(true)
}, [isInsideActive])
return (
<div className="space-y-2 rounded-lg bg-surface-muted/30 p-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs font-bold transition-all ${isExpanded ? 'text-primary bg-primary/5' : 'text-text-muted hover:text-primary hover:bg-primary/5'}`}
>
<GraduationCap className={`h-4 w-4 ${isExpanded ? 'text-primary' : 'text-text-subtle'}`} />
<span className="uppercase tracking-wide">Advanced</span>
<ChevronDown className={`ml-auto h-3.5 w-3.5 transition-transform duration-300 ${isExpanded ? '' : '-rotate-90'}`} />
</button>
{isExpanded && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1 duration-300">
{ADVANCED_GROUP.map(k => grouped[k] && (
<div key={k} className="space-y-2 py-1">
<div className="flex items-center gap-2 px-3 text-[10px] font-bold uppercase tracking-widest text-text-subtle/50">
<span className="h-[1px] w-2 bg-surface-border"></span>
{humanize(k)}
</div>
<ul className="ml-4 space-y-1 border-l border-surface-border/50 pl-4">
{grouped[k].map(v => (
<SidebarLink key={v.slug} version={v} collectionSlug={collectionSlug} activePath={activePath} />
))}
</ul>
</div>
))}
</div>
)}
</div>
)
}
function SidebarLink({ version, collectionSlug, activePath }: { version: DocVersionOption; collectionSlug: string; activePath: string }) {
const href = `/docs/${collectionSlug}/${version.slug}`
const isPageActive = activePath === href
return (
<li>
<Link
href={href}
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-all duration-200 ${isPageActive
? 'bg-primary/10 text-primary font-medium shadow-sm'
: 'text-text-muted hover:text-heading hover:bg-surface-muted'
}`}
>
{isPageActive && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
<span className="truncate">{version.title}</span>
{!isPageActive && <ChevronRight className="ml-auto h-3 w-3 opacity-0 transition-all -translate-x-2 group-hover:opacity-30 group-hover:translate-x-0" />}
</Link>
</li>
)
}

View File

@ -1,21 +1,25 @@
import { getDocCollections } from './resources.server'
import DocsSidebar from './DocsSidebar'
import Navbar from '@components/Navbar'
import Footer from '@components/Footer'
import { getDocCollections } from "./resources.server";
import DocsSidebar from "./DocsSidebar";
import UnifiedNavigation from "@components/UnifiedNavigation";
import Footer from "@components/Footer";
export default async function DocsLayout({ children }: { children: React.ReactNode }) {
const collections = await getDocCollections()
export default async function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
const collections = await getDocCollections();
return (
<div className="flex min-h-screen flex-col bg-background text-text">
<Navbar />
<div className="mx-auto flex w-full max-w-[1536px] items-start">
<DocsSidebar collections={collections} />
<main className="min-h-[calc(100vh-64px)] flex-1 overflow-x-hidden py-8 px-4 sm:px-8 lg:px-10">
{children}
</main>
</div>
<Footer />
</div>
)
return (
<div className="flex min-h-screen flex-col bg-background text-text">
<UnifiedNavigation />
<div className="mx-auto flex w-full max-w-[1536px] items-start">
<DocsSidebar collections={collections} />
<main className="min-h-[calc(100vh-64px)] flex-1 overflow-x-hidden py-8 px-4 sm:px-8 lg:px-10">
{children}
</main>
</div>
<Footer />
</div>
);
}

View File

@ -1,6 +1,6 @@
'use client'
"use client";
export const dynamic = 'error'
export const dynamic = "error";
import {
AppWindow,
@ -17,88 +17,113 @@ import {
Sparkles,
Terminal,
Users,
} from 'lucide-react'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import { useUserStore } from '../lib/userStore'
import { useLanguage } from '../i18n/LanguageProvider'
import { translations } from '../i18n/translations'
} from "lucide-react";
import Footer from "../components/Footer";
import UnifiedNavigation from "../components/UnifiedNavigation";
import { useUserStore } from "../lib/userStore";
import { useLanguage } from "../i18n/LanguageProvider";
import { translations } from "../i18n/translations";
const iconMap: Record<string, any> = {
// English keys
'Create your app': PlusCircle,
'Register your app': ShieldCheck,
'Deploy your app': Users,
'Add a new user to your project': Users,
'Register a new application': AppWindow,
'Deploy your application': Command,
'Invite a user': MousePointerClick,
'Get started': Sparkles,
'Creating your application': AppWindow,
'More about Authentication': ShieldCheck,
'Understanding Authorization': Lock,
'Machine-to-Machine': Layers,
'Connect via CLI': Terminal,
'REST & Admin APIs': Link,
"Create your app": PlusCircle,
"Register your app": ShieldCheck,
"Deploy your app": Users,
"Add a new user to your project": Users,
"Register a new application": AppWindow,
"Deploy your application": Command,
"Invite a user": MousePointerClick,
"Get started": Sparkles,
"Creating your application": AppWindow,
"More about Authentication": ShieldCheck,
"Understanding Authorization": Lock,
"Machine-to-Machine": Layers,
"Connect via CLI": Terminal,
"REST & Admin APIs": Link,
// Chinese keys
'创建您的应用': PlusCircle,
'注册您的应用': ShieldCheck,
'部署您的应用': Users,
'向项目添加新用户': Users,
'注册新应用程序': AppWindow,
'部署您的应用程序': Command,
'邀请用户': MousePointerClick,
'开始使用': Sparkles,
'创建您的应用程序': AppWindow,
'关于身份验证': ShieldCheck,
'了解授权': Lock,
'机器对机器': Layers,
'通过 CLI 连接': Terminal,
}
创建您的应用: PlusCircle,
注册您的应用: ShieldCheck,
部署您的应用: Users,
向项目添加新用户: Users,
注册新应用程序: AppWindow,
部署您的应用程序: Command,
邀请用户: MousePointerClick,
开始使用: Sparkles,
创建您的应用程序: AppWindow,
关于身份验证: ShieldCheck,
了解授权: Lock,
机器对机器: Layers,
"通过 CLI 连接": Terminal,
};
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
const nextSteps = [
{ title: 'Add a new user to your project', status: 'NEW', icon: Users },
{ title: 'Register a new application', status: 'NEW', icon: AppWindow },
{ title: 'Deploy your application', status: 'READY', icon: Command },
{ title: 'Invite a user', status: 'READY', icon: MousePointerClick },
]
{ title: "Add a new user to your project", status: "NEW", icon: Users },
{ title: "Register a new application", status: "NEW", icon: AppWindow },
{ title: "Deploy your application", status: "READY", icon: Command },
{ title: "Invite a user", status: "READY", icon: MousePointerClick },
];
const stats = [
{ value: '~150k', label: 'Applications integrated with Cloud-Neutral Toolkit' },
{ value: '~330k', label: 'Daily active users' },
{ value: '7', label: 'Go check out our examples & guides' },
]
{
value: "~150k",
label: "Applications integrated with Cloud-Neutral Toolkit",
},
{ value: "~330k", label: "Daily active users" },
{ value: "7", label: "Go check out our examples & guides" },
];
const shortcuts = [
{ title: 'Get started', description: 'An overview of using Cloud-Neutral Toolkit', icon: Sparkles },
{ title: 'Creating your application', description: 'Integrate Cloud-Neutral Toolkit into your application', icon: AppWindow },
{
title: 'More about Authentication',
description: 'Understand all about authenticating with Cloud-Neutral Toolkit',
title: "Get started",
description: "An overview of using Cloud-Neutral Toolkit",
icon: Sparkles,
},
{
title: "Creating your application",
description: "Integrate Cloud-Neutral Toolkit into your application",
icon: AppWindow,
},
{
title: "More about Authentication",
description:
"Understand all about authenticating with Cloud-Neutral Toolkit",
icon: ShieldCheck,
},
{
title: 'Understanding Authorization',
description: 'Scope out all about authorization using Cloud-Neutral Toolkit',
title: "Understanding Authorization",
description:
"Scope out all about authorization using Cloud-Neutral Toolkit",
icon: Lock,
},
{ title: 'Machine-to-Machine', description: 'Integrate Cloud-Neutral Toolkit into your services', icon: Layers },
{ title: 'Connect via CLI', description: 'Connect Cloud-Neutral Toolkit with your application via CLI', icon: Terminal },
{
title: 'REST & Admin APIs',
description: 'Programmatically integrate Cloud-Neutral Toolkit into your application',
title: "Machine-to-Machine",
description: "Integrate Cloud-Neutral Toolkit into your services",
icon: Layers,
},
{
title: "Connect via CLI",
description: "Connect Cloud-Neutral Toolkit with your application via CLI",
icon: Terminal,
},
{
title: "REST & Admin APIs",
description:
"Programmatically integrate Cloud-Neutral Toolkit into your application",
icon: Link,
},
]
];
export default function HomePage() {
return (
<div className="min-h-screen bg-background text-text transition-colors duration-150">
<div className="absolute inset-0 bg-gradient-app-from opacity-20" aria-hidden />
<div
className="absolute inset-0 bg-gradient-app-from opacity-20"
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 pb-20">
<Navbar />
<UnifiedNavigation />
<main className="space-y-12 pt-10">
<HeroSection />
<NextStepsSection />
@ -108,20 +133,24 @@ export default function HomePage() {
<Footer />
</div>
</div>
)
);
}
function HeroSection() {
const { user } = useUserStore()
const { language } = useLanguage()
const t = translations[language].marketing.home
export function HeroSection() {
const { user } = useUserStore();
const { language } = useLanguage();
const t = translations[language].marketing.home;
return (
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="flex flex-col justify-center space-y-8">
<div className="space-y-4">
<p className="font-semibold uppercase tracking-wider text-text-subtle">{t.hero.eyebrow}</p>
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-6xl">{t.hero.title}</h1>
<p className="font-semibold uppercase tracking-wider text-text-subtle">
{t.hero.eyebrow}
</p>
<h1 className="text-4xl font-bold tracking-tight text-heading sm:text-6xl">
{t.hero.title}
</h1>
<p className="text-lg text-text-muted">{t.hero.subtitle}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
@ -158,9 +187,12 @@ function HeroSection() {
</div>
<div className="flex flex-col gap-4">
{t.heroCards.map((card, index: number) => {
const Icon = getIcon(card.title, PlusCircle)
const Icon = getIcon(card.title, PlusCircle);
return (
<div key={card.title} className="group flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition hover:border-primary/50 hover:bg-surface-hover">
<div
key={card.title}
className="group flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition hover:border-primary/50 hover:bg-surface-hover"
>
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
<Icon className="h-5 w-5" />
</div>
@ -172,87 +204,108 @@ function HeroSection() {
</button>
</div>
</div>
)
);
})}
</div>
</section>
)
);
}
function NextStepsSection() {
const { language } = useLanguage()
const t = translations[language].marketing.home
export function NextStepsSection() {
const { language } = useLanguage();
const t = translations[language].marketing.home;
return (
<section className="space-y-4">
<header className="flex items-center gap-3 text-sm text-text-muted">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">{t.nextSteps.title}</p>
<span className="rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">{t.nextSteps.badge}</span>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
{t.nextSteps.title}
</p>
<span className="rounded-full bg-surface-muted px-3 py-1 text-xs font-semibold text-primary">
{t.nextSteps.badge}
</span>
</header>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{t.nextSteps.items.map((item, index: number) => {
const Icon = getIcon(item.title, Users)
const Icon = getIcon(item.title, Users);
return (
<div key={index} className="flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 shadow-lg shadow-shadow-sm">
<div
key={index}
className="flex items-start gap-3 rounded-xl border border-surface-border bg-surface p-4 shadow-lg shadow-shadow-sm"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/15 text-primary">
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-primary-muted">
<span className="rounded-full bg-primary/20 px-2 py-0.5">{item.status}</span>
<span className="rounded-full bg-primary/20 px-2 py-0.5">
{item.status}
</span>
</div>
<p className="text-sm font-semibold text-heading">{item.title}</p>
<p className="text-sm font-semibold text-heading">
{item.title}
</p>
<button className="inline-flex items-center gap-1 text-xs font-semibold text-primary transition hover:text-primary-hover">
Learn more
<ArrowRight className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
)
);
})}
</div>
</section>
)
);
}
function StatsSection() {
const { language } = useLanguage()
const t = translations[language].marketing.home
export function StatsSection() {
const { language } = useLanguage();
const t = translations[language].marketing.home;
return (
<section className="rounded-2xl border border-surface-border bg-gradient-to-r from-surface-muted via-surface/0 to-surface-muted p-6 shadow-inner shadow-shadow-sm">
<div className="grid gap-6 md:grid-cols-3">
{t.stats.map((stat, index: number) => (
<div key={index} className="space-y-1 text-center md:text-left">
<div className="text-3xl font-semibold text-heading">{stat.value}</div>
<div className="text-3xl font-semibold text-heading">
{stat.value}
</div>
<p className="text-sm text-text-muted">{stat.label}</p>
</div>
))}
</div>
</section>
)
);
}
function ShortcutsSection() {
const { language } = useLanguage()
const t = translations[language].marketing.home
export function ShortcutsSection() {
const { language } = useLanguage();
const t = translations[language].marketing.home;
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">{t.shortcuts.title}</p>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-subtle">
{t.shortcuts.title}
</p>
<p className="text-sm text-text-muted">{t.shortcuts.subtitle}</p>
</div>
<div className="flex gap-2 text-xs font-semibold text-primary">
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">{t.shortcuts.buttons.start}</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">{t.shortcuts.buttons.docs}</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">{t.shortcuts.buttons.guides}</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
{t.shortcuts.buttons.start}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
{t.shortcuts.buttons.docs}
</button>
<button className="rounded-full border border-surface-border bg-surface-muted px-3 py-1 transition hover:bg-surface-hover">
{t.shortcuts.buttons.guides}
</button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{t.shortcuts.items.map((item, index: number) => {
const Icon = getIcon(item.title, Sparkles)
const Icon = getIcon(item.title, Sparkles);
return (
<a
key={index}
@ -263,16 +316,21 @@ function ShortcutsSection() {
<Icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-1">
<div className="text-sm font-semibold text-heading">{item.title}</div>
<div className="text-sm font-semibold text-heading">
{item.title}
</div>
<p className="text-sm text-text-muted">{item.description}</p>
</div>
<ArrowRight className="ml-auto h-4 w-4 text-text-subtle transition group-hover:text-primary" aria-hidden />
<ArrowRight
className="ml-auto h-4 w-4 text-text-subtle transition group-hover:text-primary"
aria-hidden
/>
</a>
)
);
})}
</div>
</section>
)
);
}
function LogoPill({ label }: { label: string }) {
@ -281,5 +339,5 @@ function LogoPill({ label }: { label: string }) {
<div className="h-2 w-2 rounded-full bg-success" />
{label}
</span>
)
);
}

View File

@ -0,0 +1,210 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useMemo, type ComponentType } from 'react'
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
import { SidebarHeader, SidebarContent } from '../../../components/layout/SidebarRoot'
const registry = getExtensionRegistry()
const PlaceholderIcon: ComponentType<{ className?: string }> = () => null
interface NavItem {
href: string
label: string
description: string
Icon: ComponentType<{ className?: string }>
disabled: boolean
}
interface NavSection {
title: string
items: NavItem[]
}
function isActive(pathname: string, href: string) {
if (href === '/panel') {
return pathname === '/panel'
}
return pathname.startsWith(href)
}
export interface PanelSidebarContentProps {
onNavigate?: () => void
}
export function PanelSidebarContent({ onNavigate }: PanelSidebarContentProps) {
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const navSections = useMemo<NavSection[]>(() => {
return registry.sidebar
.map((section) => {
const items = section.items
.map((item) => {
const { route } = item
const guardResult = route.guard ? resolveAccess(user, route.guard) : { allowed: true }
const requiresRole = Boolean(route.guard?.roles?.length)
if (requiresRole && !guardResult.allowed) {
return null
}
const disabledByGuard = !requiresRole && !guardResult.allowed
const disabled =
item.disabled ||
disabledByGuard ||
(requiresSetup && route.path !== '/panel/account')
const Icon = route.icon ?? PlaceholderIcon
return {
href: route.path,
label: route.label,
description: route.description ?? '',
Icon,
disabled,
}
})
.filter((value): value is NavItem => Boolean(value))
if (items.length === 0) {
return null
}
return {
title: section.title,
items,
}
})
.filter((value): value is NavSection => Boolean(value))
}, [requiresSetup, user])
return (
<>
<SidebarHeader className="space-y-1 text-[var(--color-text)] transition-colors mb-6">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">XControl</p>
<h2 className="text-lg font-bold text-[var(--color-heading)]">User Center</h2>
<p className="text-sm text-[var(--color-text-subtle)]"></p>
{requiresSetup ? (
<div className="mt-4 rounded-[var(--radius-lg)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)] transition-colors">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.lockedMessage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/panel/account?setupMfa=1"
onClick={onNavigate}
className="inline-flex items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--color-primary-hover)]"
>
{copy.actions.setup}
</Link>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-primary-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary)] transition-colors hover:border-[color:var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
>
{copy.actions.docs}
</a>
</div>
</div>
) : null}
</SidebarHeader>
<SidebarContent className="flex flex-col gap-6">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled)
return (
<div key={section.title} className="space-y-3">
<p
className={`text-xs font-semibold uppercase tracking-wide ${sectionDisabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)]'
}`}
>
{section.title}
</p>
<div className={`space-y-2 ${sectionDisabled ? 'opacity-60' : ''}`}>
{section.items.map((item) => {
const active = isActive(pathname, item.href)
const { Icon } = item
const baseClasses = [
'group flex items-center gap-3 rounded-[var(--radius-xl)] border px-3 py-3 text-sm transition-colors',
]
if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60',
)
} else {
baseClasses.push(
'border-transparent text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-primary)]',
)
}
if (active) {
baseClasses.push(
'border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)] shadow-[var(--shadow-sm)]',
)
}
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-[var(--color-primary)] text-[var(--color-primary-foreground)]')
} else if (item.disabled) {
iconClasses.push('bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60')
} else {
iconClasses.push(
'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] group-hover:bg-[var(--color-primary-muted)] group-hover:text-[var(--color-primary)]',
)
}
const descriptionClasses = [
'text-xs transition-colors',
item.disabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]',
]
const content = (
<div className={baseClasses.join(' ')}>
<span className={iconClasses.join(' ')}>
<Icon className="h-4 w-4" />
</span>
<span className="flex flex-col">
<span className="font-semibold">{item.label}</span>
<span className={descriptionClasses.join(' ')}>{item.description}</span>
</span>
</div>
)
if (item.disabled) {
return (
<div key={item.href} aria-disabled={true} className="select-none">
{content}
</div>
)
}
return (
<Link key={item.href} href={item.href} onClick={onNavigate}>
{content}
</Link>
)
})}
</div>
</div>
)
})}
</SidebarContent>
</>
)
}

View File

@ -1,213 +1,19 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useMemo, type ComponentType } from 'react'
import React from 'react'
import { SidebarRoot } from '../../../components/layout/SidebarRoot'
import { PanelSidebarContent, PanelSidebarContentProps } from './PanelSidebarContent'
import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess } from '@lib/accessControl'
import { useUserStore } from '@lib/userStore'
const registry = getExtensionRegistry()
const PlaceholderIcon: ComponentType<{ className?: string }> = () => null
export interface SidebarProps {
export interface SidebarProps extends PanelSidebarContentProps {
className?: string
onNavigate?: () => void
}
interface NavItem {
href: string
label: string
description: string
Icon: ComponentType<{ className?: string }>
disabled: boolean
}
interface NavSection {
title: string
items: NavItem[]
}
function isActive(pathname: string, href: string) {
if (href === '/panel') {
return pathname === '/panel'
}
return pathname.startsWith(href)
}
export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const user = useUserStore((state) => state.user)
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const navSections = useMemo<NavSection[]>(() => {
return registry.sidebar
.map((section) => {
const items = section.items
.map((item) => {
const { route } = item
const guardResult = route.guard ? resolveAccess(user, route.guard) : { allowed: true }
const requiresRole = Boolean(route.guard?.roles?.length)
if (requiresRole && !guardResult.allowed) {
return null
}
const disabledByGuard = !requiresRole && !guardResult.allowed
const disabled =
item.disabled ||
disabledByGuard ||
(requiresSetup && route.path !== '/panel/account')
const Icon = route.icon ?? PlaceholderIcon
return {
href: route.path,
label: route.label,
description: route.description ?? '',
Icon,
disabled,
}
})
.filter((value): value is NavItem => Boolean(value))
if (items.length === 0) {
return null
}
return {
title: section.title,
items,
}
})
.filter((value): value is NavSection => Boolean(value))
}, [requiresSetup, user])
return (
<aside
className={`flex h-full w-64 flex-col gap-6 border-r border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-6 text-[var(--color-text)] shadow-[var(--shadow-md)] backdrop-blur transition-colors ${className}`}
<SidebarRoot
className={`w-64 border-r border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] p-6 text-[var(--color-text)] shadow-[var(--shadow-md)] backdrop-blur ${className}`}
>
<div className="space-y-1 text-[var(--color-text)] transition-colors">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">XControl</p>
<h2 className="text-lg font-bold text-[var(--color-heading)]">User Center</h2>
<p className="text-sm text-[var(--color-text-subtle)]"></p>
</div>
{requiresSetup ? (
<div className="rounded-[var(--radius-lg)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)] transition-colors">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.lockedMessage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/panel/account?setupMfa=1"
onClick={onNavigate}
className="inline-flex items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary-foreground)] shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--color-primary-hover)]"
>
{copy.actions.setup}
</Link>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-[color:var(--color-primary-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-primary)] transition-colors hover:border-[color:var(--color-primary)] hover:bg-[var(--color-primary-muted)]"
>
{copy.actions.docs}
</a>
</div>
</div>
) : null}
<nav className="flex flex-1 flex-col gap-6 overflow-y-auto">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled)
return (
<div key={section.title} className="space-y-3">
<p
className={`text-xs font-semibold uppercase tracking-wide ${
sectionDisabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)]'
}`}
>
{section.title}
</p>
<div className={`space-y-2 ${sectionDisabled ? 'opacity-60' : ''}`}>
{section.items.map((item) => {
const active = isActive(pathname, item.href)
const { Icon } = item
const baseClasses = [
'group flex items-center gap-3 rounded-[var(--radius-xl)] border px-3 py-3 text-sm transition-colors',
]
if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60',
)
} else {
baseClasses.push(
'border-transparent text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-primary)]',
)
}
if (active) {
baseClasses.push(
'border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)] shadow-[var(--shadow-sm)]',
)
}
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-[var(--color-primary)] text-[var(--color-primary-foreground)]')
} else if (item.disabled) {
iconClasses.push('bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60')
} else {
iconClasses.push(
'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] group-hover:bg-[var(--color-primary-muted)] group-hover:text-[var(--color-primary)]',
)
}
const descriptionClasses = [
'text-xs transition-colors',
item.disabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]',
]
const content = (
<div className={baseClasses.join(' ')}>
<span className={iconClasses.join(' ')}>
<Icon className="h-4 w-4" />
</span>
<span className="flex flex-col">
<span className="font-semibold">{item.label}</span>
<span className={descriptionClasses.join(' ')}>{item.description}</span>
</span>
</div>
)
if (item.disabled) {
return (
<div key={item.href} aria-disabled={true} className="select-none">
{content}
</div>
)
}
return (
<Link key={item.href} href={item.href} onClick={onNavigate}>
{content}
</Link>
)
})}
</div>
</div>
)
})}
</nav>
</aside>
<PanelSidebarContent onNavigate={onNavigate} />
</SidebarRoot>
)
}

View File

@ -1,7 +1,23 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation'
import {
PanelLeft,
PanelRight,
Maximize2,
Minus,
X,
} from 'lucide-react'
import {
HeroSection,
NextStepsSection,
StatsSection,
ShortcutsSection
} from '@/app/page'
import Footer from '@components/Footer'
export type ChatLayoutMode = 'left' | 'right' | 'full'
const quickActions = [
{ id: 'hello', label: 'Say Hello', prompt: 'Hello, who are you?' },
@ -9,14 +25,15 @@ const quickActions = [
]
export function MoltbotChat() {
const router = useRouter()
const searchParams = useSearchParams()
const initialQuery = searchParams.get('q')
// Use a ref to track if we've processed the initial query to avoid double-sending in strict mode
const hasProcessedInitialQuery = useRef(false)
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [layout, setLayout] = useState<ChatLayoutMode>('full')
const [conversation, setConversation] = useState<
{ author: 'user' | 'ai'; text: string; timestamp: number }[]
>([])
@ -30,7 +47,7 @@ export function MoltbotChat() {
async function appendMessage(prompt: string) {
const timestamp = Date.now()
setConversation(prev => [
setConversation((prev) => [
...prev,
{ author: 'user', text: prompt, timestamp },
])
@ -38,7 +55,6 @@ export function MoltbotChat() {
setLoading(true)
try {
// Use internal proxy to handle CORS and auth
const response = await fetch('/api/moltbot/chat', {
method: 'POST',
headers: {
@ -55,12 +71,12 @@ export function MoltbotChat() {
const data = await response.json()
const reply = data.reply || data.message || JSON.stringify(data)
setConversation(prev => [
setConversation((prev) => [
...prev,
{ author: 'ai', text: reply, timestamp: Date.now() }
])
} catch (error: any) {
setConversation(prev => [
setConversation((prev) => [
...prev,
{ author: 'ai', text: `Error: ${error.message}. Please try again later.`, timestamp: Date.now() }
])
@ -82,12 +98,65 @@ export function MoltbotChat() {
}
}
return (
<div className="flex flex-col h-full rounded-2xl border border-emerald-500/30 bg-slate-950/95 shadow-2xl overflow-hidden">
const HomeContent = () => (
<main className="space-y-12 py-10">
<HeroSection />
<NextStepsSection />
<StatsSection />
<ShortcutsSection />
<Footer />
</main>
)
const renderChat = (isSidebar = false) => (
<div className={`flex flex-col rounded-2xl border border-emerald-500/30 bg-slate-950/95 shadow-2xl overflow-hidden transition-all duration-300 h-full ${isSidebar ? 'w-[420px] shrink-0 z-10' : 'w-full'}`}>
<div className="flex items-center justify-between border-b border-emerald-500/20 px-6 py-4">
<div>
<p className="text-lg font-semibold text-slate-100">Moltbot AI</p>
<p className="text-xs text-slate-400">Your personal cloud assistant.</p>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
<span className="text-xl">🦞</span>
</div>
<div>
<p className="text-base font-semibold text-slate-100 leading-tight">AI Assistant</p>
<p className="text-[10px] text-emerald-400/70 uppercase tracking-widest font-bold">Online</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); setLayout('left') }}
className={`p-1.5 rounded-lg transition-colors ${layout === 'left' ? 'text-emerald-400 bg-emerald-500/10' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}
title="Sidebar Left"
>
<PanelLeft className="size-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setLayout('right') }}
className={`p-1.5 rounded-lg transition-colors ${layout === 'right' ? 'text-emerald-400 bg-emerald-500/10' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}
title="Sidebar Right"
>
<PanelRight className="size-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setLayout('full') }}
className={`p-1.5 rounded-lg transition-colors ${layout === 'full' ? 'text-emerald-400 bg-emerald-500/10' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}
title="Fullscreen"
>
<Maximize2 className="size-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); router.push('/') }}
className={`p-1.5 rounded-lg transition-colors text-slate-400 hover:text-slate-200 hover:bg-slate-800`}
title="Minimize"
>
<Minus className="size-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); router.push('/') }}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
title="Close"
>
<X className="size-4" />
</button>
</div>
</div>
@ -95,17 +164,16 @@ export function MoltbotChat() {
{conversation.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-6 text-center">
<div className="p-4 rounded-full bg-slate-900/50 border border-slate-800">
{/* Moltbot Icon placeholder */}
<div className="w-12 h-12 flex items-center justify-center text-3xl">🦞</div>
</div>
<div className="space-y-2">
<h3 className="text-slate-200 font-medium">Welcome to Moltbot</h3>
<h3 className="text-slate-200 font-medium">Welcome to AI Assistant</h3>
<p className="text-sm text-slate-500 max-w-xs mx-auto">
Ask me anything about your infrastructure, logs, or just say hello.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-md">
{quickActions.map(action => (
<div className="grid grid-cols-1 gap-3 w-full max-w-md">
{quickActions.map((action: { id: string, label: string, prompt: string }) => (
<button
key={action.id}
onClick={() => appendMessage(action.prompt)}
@ -118,13 +186,13 @@ export function MoltbotChat() {
</div>
) : (
<ul className="space-y-4">
{conversation.map(entry => (
{conversation.map((entry: { author: string, text: string, timestamp: number }) => (
<li
key={entry.timestamp}
className={`flex flex-col ${entry.author === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={`rounded-2xl px-4 py-3 text-sm max-w-[85%] ${entry.author === 'user'
className={`rounded-2xl px-4 py-3 text-sm max-w-[90%] ${entry.author === 'user'
? 'bg-emerald-500/10 text-emerald-100 rounded-tr-none'
: 'bg-slate-900/80 text-slate-200 rounded-tl-none border border-slate-800'
}`}
@ -161,7 +229,7 @@ export function MoltbotChat() {
className="h-24 w-full rounded-2xl border border-slate-800 bg-slate-900/80 p-4 text-sm text-slate-200 focus:outline-none focus:border-emerald-500/50 resize-none"
/>
<div className="absolute bottom-3 right-3 flex items-center justify-between gap-4">
<span className="text-xs text-slate-500 hidden sm:block">Press Enter to send</span>
<span className="text-xs text-slate-500 hidden sm:block">Press Enter</span>
<button
onClick={handleSend}
disabled={loading || !message.trim()}
@ -174,4 +242,18 @@ export function MoltbotChat() {
</div>
</div>
)
if (layout === 'full') {
return renderChat(false)
}
return (
<div className="flex h-full w-full gap-4 relative overflow-hidden">
{layout === 'left' && renderChat(true)}
<div className="flex-1 h-full overflow-y-auto custom-scrollbar">
<HomeContent />
</div>
{layout === 'right' && renderChat(true)}
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
"use client";
import Link from 'next/link'
import Link from "next/link";
import {
Activity,
ArrowRight,
@ -10,101 +10,180 @@ import {
MessageSquare,
PenSquare,
Package,
} from 'lucide-react'
import Footer from '../../components/Footer'
import Navbar from '../../components/Navbar'
import { useLanguage } from '../../i18n/LanguageProvider'
import { useViewStore } from '../../components/theme/viewStore'
import Material3Layout from './Material3Layout'
} from "lucide-react";
import Footer from "../../components/Footer";
import UnifiedNavigation from "../../components/UnifiedNavigation";
import { useLanguage } from "../../i18n/LanguageProvider";
import { useViewStore } from "../../components/theme/viewStore";
import Material3Layout from "./Material3Layout";
const placeholderCount = 3
const placeholderCount = 3;
type ServiceCardData = {
key: string
name: string
description: string
href: string
icon: any
external?: boolean
}
key: string;
name: string;
description: string;
href: string;
icon: any;
external?: boolean;
};
const ServiceCard = ({ service, view, isChinese }: { service: ServiceCardData, view: 'classic' | 'material', isChinese: boolean }) => {
const isMaterial = view === 'material'
const ServiceCard = ({
service,
view,
isChinese,
}: {
service: ServiceCardData;
view: "classic" | "material";
isChinese: boolean;
}) => {
const isMaterial = view === "material";
const cardContent = (
<div className={`group flex h-full flex-col justify-between rounded-xl p-5 transition ${isMaterial
? 'border border-surface-border bg-surface hover:-translate-y-[1px] hover:border-primary/50 hover:bg-background-muted'
: 'border border-white/10 bg-white/5 hover:-translate-y-[1px] hover:border-indigo-400/50 hover:bg-slate-900/60'
}`}>
<div
className={`group flex h-full flex-col justify-between rounded-xl p-5 transition ${
isMaterial
? "border border-surface-border bg-surface hover:-translate-y-[1px] hover:border-primary/50 hover:bg-background-muted"
: "border border-white/10 bg-white/5 hover:-translate-y-[1px] hover:border-indigo-400/50 hover:bg-slate-900/60"
}`}
>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${isMaterial ? 'bg-primary/15 text-primary' : 'bg-indigo-500/15 text-indigo-200'
}`}>
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
isMaterial
? "bg-primary/15 text-primary"
: "bg-indigo-500/15 text-indigo-200"
}`}
>
<service.icon className="h-5 w-5" aria-hidden />
</div>
<div className="space-y-1">
<div className={`text-sm font-semibold ${isMaterial ? 'text-heading' : 'text-white'}`}>{service.name}</div>
<p className={`text-sm ${isMaterial ? 'text-text-muted' : 'text-slate-300'}`}>{service.description}</p>
<div
className={`text-sm font-semibold ${isMaterial ? "text-heading" : "text-white"}`}
>
{service.name}
</div>
<p
className={`text-sm ${isMaterial ? "text-text-muted" : "text-slate-300"}`}
>
{service.description}
</p>
</div>
</div>
<span className={`mt-4 inline-flex items-center gap-1 text-xs font-semibold transition ${isMaterial ? 'text-primary group-hover:text-primary-hover' : 'text-indigo-200 group-hover:text-white'
}`}>
{isChinese ? '打开' : 'Open'}
<span
className={`mt-4 inline-flex items-center gap-1 text-xs font-semibold transition ${
isMaterial
? "text-primary group-hover:text-primary-hover"
: "text-indigo-200 group-hover:text-white"
}`}
>
{isChinese ? "打开" : "Open"}
<ArrowRight className="h-4 w-4" aria-hidden />
</span>
</div>
)
);
if (service.external) {
return (
<a href={service.href} target="_blank" rel="noopener noreferrer" className="block">
<a
href={service.href}
target="_blank"
rel="noopener noreferrer"
className="block"
>
{cardContent}
</a>
)
);
}
return (
<Link href={service.href} className="block">
{cardContent}
</Link>
)
}
);
};
const PlaceholderCard = ({ view, isChinese }: { view: 'classic' | 'material', isChinese: boolean }) => {
const isMaterial = view === 'material'
const placeholderLabel = isChinese ? '更多服务即将上线' : 'More services coming soon'
const PlaceholderCard = ({
view,
isChinese,
}: {
view: "classic" | "material";
isChinese: boolean;
}) => {
const isMaterial = view === "material";
const placeholderLabel = isChinese
? "更多服务即将上线"
: "More services coming soon";
const placeholderDescription = isChinese
? '预留卡片位置,持续扩充入口。'
: 'Reserved slots for new service entries.'
? "预留卡片位置,持续扩充入口。"
: "Reserved slots for new service entries.";
return (
<div className={`flex h-full flex-col justify-between rounded-xl border border-dashed p-5 ${isMaterial ? 'border-surface-border-strong bg-surface text-text-muted' : 'border-white/15 bg-white/5 text-slate-300'
}`}>
<div
className={`flex h-full flex-col justify-between rounded-xl border border-dashed p-5 ${
isMaterial
? "border-surface-border-strong bg-surface text-text-muted"
: "border-white/15 bg-white/5 text-slate-300"
}`}
>
<div className="space-y-2">
<div className={`flex h-10 w-10 items-center justify-center rounded-full border border-dashed text-sm ${isMaterial ? 'border-surface-border-strong text-text-subtle' : 'border-white/20 text-slate-400'
}`}>
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dashed text-sm ${
isMaterial
? "border-surface-border-strong text-text-subtle"
: "border-white/20 text-slate-400"
}`}
>
<FileText className="h-4 w-4" aria-hidden />
</div>
<div className={`text-sm font-semibold ${isMaterial ? 'text-heading' : 'text-white/80'}`}>{placeholderLabel}</div>
<p className={`text-sm ${isMaterial ? 'text-text-subtle' : 'text-slate-400'}`}>{placeholderDescription}</p>
<div
className={`text-sm font-semibold ${isMaterial ? "text-heading" : "text-white/80"}`}
>
{placeholderLabel}
</div>
<p
className={`text-sm ${isMaterial ? "text-text-subtle" : "text-slate-400"}`}
>
{placeholderDescription}
</p>
</div>
<span className={`mt-4 text-xs font-semibold ${isMaterial ? 'text-text-subtle' : 'text-slate-400'}`}>
{isChinese ? '敬请期待' : 'Stay tuned'}
<span
className={`mt-4 text-xs font-semibold ${isMaterial ? "text-text-subtle" : "text-slate-400"}`}
>
{isChinese ? "敬请期待" : "Stay tuned"}
</span>
</div>
)
}
);
};
const ServiceGrid = ({ view, services, isChinese }: { view: 'classic' | 'material', services: ServiceCardData[], isChinese: boolean }) => {
const ServiceGrid = ({
view,
services,
isChinese,
}: {
view: "classic" | "material";
services: ServiceCardData[];
isChinese: boolean;
}) => {
return (
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{services.map((service) => (
<ServiceCard key={service.key} service={service} view={view} isChinese={isChinese} />
<ServiceCard
key={service.key}
service={service}
view={view}
isChinese={isChinese}
/>
))}
{Array.from({ length: placeholderCount }).map((_, index) => (
<PlaceholderCard key={`placeholder-${index}`} view={view} isChinese={isChinese} />
<PlaceholderCard
key={`placeholder-${index}`}
view={view}
isChinese={isChinese}
/>
))}
</section>
)
}
);
};
const ClawdbotLogo = (props: any) => (
<img
@ -112,40 +191,115 @@ const ClawdbotLogo = (props: any) => (
alt="Clawdbot"
{...props}
/>
)
);
export default function ServicesPage() {
const { view, isHydrated } = useViewStore()
const { language } = useLanguage()
const isChinese = language === 'zh'
const { view, isHydrated } = useViewStore();
const { language } = useLanguage();
const isChinese = language === "zh";
const services: ServiceCardData[] = [
{ key: 'editor', name: isChinese ? '编辑器' : 'Editor', description: isChinese ? 'Markdown 发布与排版的在线编辑器。' : 'Markdown publishing and layout editor.', href: 'https://markdown-publisher.svc.plus', icon: PenSquare, external: true, },
{ key: 'wechat-to-markdown', name: isChinese ? '微信转 Markdown' : 'WeChat to Markdown', description: isChinese ? '一键将公众号内容转换为 Markdown。' : 'Convert WeChat articles into Markdown.', href: 'https://wechat-to-markdown.svc.plus', icon: MessageSquare, external: true, },
{ key: 'page-reading', name: isChinese ? 'Page Reading Agent' : 'Page Reading Agent', description: isChinese ? '智能网页阅读与分析服务。' : 'Intelligent web page reading and analysis service.', href: 'https://page-reading.svc.plus', icon: FileText, external: true, },
{ key: 'artifact', name: isChinese ? '制品 / 镜像' : 'Artifact / Mirror', description: isChinese ? '获取核心制品、镜像与下载资源。' : 'Get core artifacts, mirrors, and downloads.', href: '/download', icon: Package, },
{ key: 'cloudIac', name: isChinese ? '云 IaC 目录' : 'Cloud IaC Catalog', description: isChinese ? '浏览云基础设施目录与自动化蓝图。' : 'Browse cloud IaC catalog and automation blueprints.', href: '/cloud_iac', icon: Layers, },
{ key: 'insight', name: isChinese ? 'Insight 工作台' : 'Insight Workbench', description: isChinese ? '进入观测、告警与智能协作控制面。' : 'Observability, alerts, and AI-assisted operations.', href: '/insight', icon: Activity, },
{ key: 'docs', name: isChinese ? '文档 / 解决方案' : 'Docs / Solutions', description: isChinese ? '阅读文档、方案与产品指南。' : 'Read documentation, solutions, and guides.', href: '/docs', icon: BookOpen, },
{ key: 'moltbot', name: isChinese ? 'Moltbot 服务' : 'Moltbot Service', description: isChinese ? 'Moltbot 节点管理服务。' : 'Moltbot node management service.', href: 'https://clawdbot.svc.plus/', icon: ClawdbotLogo, external: true, },
]
{
key: "editor",
name: isChinese ? "编辑器" : "Editor",
description: isChinese
? "Markdown 发布与排版的在线编辑器。"
: "Markdown publishing and layout editor.",
href: "https://markdown-publisher.svc.plus",
icon: PenSquare,
external: true,
},
{
key: "wechat-to-markdown",
name: isChinese ? "微信转 Markdown" : "WeChat to Markdown",
description: isChinese
? "一键将公众号内容转换为 Markdown。"
: "Convert WeChat articles into Markdown.",
href: "https://wechat-to-markdown.svc.plus",
icon: MessageSquare,
external: true,
},
{
key: "page-reading",
name: isChinese ? "Page Reading Agent" : "Page Reading Agent",
description: isChinese
? "智能网页阅读与分析服务。"
: "Intelligent web page reading and analysis service.",
href: "https://page-reading.svc.plus",
icon: FileText,
external: true,
},
{
key: "artifact",
name: isChinese ? "制品 / 镜像" : "Artifact / Mirror",
description: isChinese
? "获取核心制品、镜像与下载资源。"
: "Get core artifacts, mirrors, and downloads.",
href: "/download",
icon: Package,
},
{
key: "cloudIac",
name: isChinese ? "云 IaC 目录" : "Cloud IaC Catalog",
description: isChinese
? "浏览云基础设施目录与自动化蓝图。"
: "Browse cloud IaC catalog and automation blueprints.",
href: "/cloud_iac",
icon: Layers,
},
{
key: "insight",
name: isChinese ? "Insight 工作台" : "Insight Workbench",
description: isChinese
? "进入观测、告警与智能协作控制面。"
: "Observability, alerts, and AI-assisted operations.",
href: "/insight",
icon: Activity,
},
{
key: "docs",
name: isChinese ? "文档 / 解决方案" : "Docs / Solutions",
description: isChinese
? "阅读文档、方案与产品指南。"
: "Read documentation, solutions, and guides.",
href: "/docs",
icon: BookOpen,
},
{
key: "moltbot",
name: isChinese ? "Moltbot 服务" : "Moltbot Service",
description: isChinese
? "Moltbot 节点管理服务。"
: "Moltbot node management service.",
href: "https://clawdbot.svc.plus/",
icon: ClawdbotLogo,
external: true,
},
];
if (!isHydrated) {
return null
return null;
}
if (view === 'material') {
if (view === "material") {
return (
<Material3Layout>
<div className="mb-10">
<h2 className="text-heading text-4xl font-black tracking-tight mb-2">Service Overview</h2>
<h2 className="text-heading text-4xl font-black tracking-tight mb-2">
Service Overview
</h2>
<p className="text-text-muted text-lg max-w-2xl">
Real-time metrics and system health for your current production environment.
Real-time metrics and system health for your current production
environment.
</p>
</div>
<ServiceGrid view="material" services={services} isChinese={isChinese} />
<ServiceGrid
view="material"
services={services}
isChinese={isChinese}
/>
</Material3Layout>
)
);
}
return (
@ -155,25 +309,31 @@ export default function ServicesPage() {
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 pb-20">
<Navbar />
<UnifiedNavigation />
<main className="space-y-10 pt-10">
<header className="space-y-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-slate-400">
{isChinese ? '更多服务' : 'More services'}
{isChinese ? "更多服务" : "More services"}
</p>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">
{isChinese ? '在这里进入所有扩展入口' : 'Access every extended entry point'}
{isChinese
? "在这里进入所有扩展入口"
: "Access every extended entry point"}
</h1>
<p className="max-w-2xl text-sm text-slate-300">
{isChinese
? '沿用当前主页布局的卡片网格,把新增工具与服务统一收拢。'
: 'A card grid aligned with the current homepage layout for all services.'}
? "沿用当前主页布局的卡片网格,把新增工具与服务统一收拢。"
: "A card grid aligned with the current homepage layout for all services."}
</p>
</header>
<ServiceGrid view="classic" services={services} isChinese={isChinese} />
<ServiceGrid
view="classic"
services={services}
isChinese={isChinese}
/>
</main>
<Footer />
</div>
</div>
)
);
}

View File

@ -0,0 +1,70 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Terminal,
LayoutDashboard,
Rocket,
Database,
Key,
History,
Settings,
Plus,
} from 'lucide-react'
import { SidebarHeader, SidebarContent, SidebarFooter } from './layout/SidebarRoot'
const navItems = [
{ href: '/panel', label: 'Console', icon: LayoutDashboard },
{ href: '/deployments', label: 'Deployments', icon: Rocket },
{ href: '/resources', label: 'Resources', icon: Database },
{ href: '/api-keys', label: 'API Keys', icon: Key },
{ href: '/logs', label: 'Logs', icon: History },
{ href: '/settings', label: 'Settings', icon: Settings },
]
export function AppSidebarContent() {
const pathname = usePathname()
return (
<>
<SidebarHeader className="mb-8">
<div className="flex items-center gap-3 px-2">
<div className="size-10 bg-primary/10 rounded-full flex items-center justify-center text-primary">
<Terminal className="size-5" />
</div>
<div className="flex flex-col">
<h1 className="text-text dark:text-heading text-base font-bold leading-tight">console.svc.plus</h1>
<p className="text-primary text-xs font-medium uppercase tracking-wider">Eye-Care Mode</p>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<nav className="flex flex-col gap-1">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors font-medium ${pathname === item.href
? 'bg-primary/10 text-primary'
: 'text-text-muted hover:bg-background-muted'
}`}
>
<item.icon className="size-5" />
<span className="text-sm">{item.label}</span>
</Link>
))}
</nav>
</SidebarContent>
<SidebarFooter>
<button className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-sm hover:opacity-90 transition-opacity">
<Plus className="size-5" />
<span>New Project</span>
</button>
</SidebarFooter>
</>
)
}

View File

@ -1,66 +1,14 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Terminal,
LayoutDashboard,
Rocket,
Database,
Key,
History,
Settings,
Plus,
} from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/deployments', label: 'Deployments', icon: Rocket },
{ href: '/resources', label: 'Resources', icon: Database },
{ href: '/api-keys', label: 'API Keys', icon: Key },
{ href: '/logs', label: 'Logs', icon: History },
{ href: '/settings', label: 'Settings', icon: Settings },
]
import React from 'react'
import { SidebarRoot } from './layout/SidebarRoot'
import { AppSidebarContent } from './AppSidebarContent'
export function Sidebar() {
const pathname = usePathname()
return (
<aside className="w-64 border-r border-surface-border flex-col justify-between p-6 bg-background dark:bg-background hidden md:flex">
<div className="flex flex-col gap-8">
{/* Brand */}
<div className="flex items-center gap-3 px-2">
<div className="size-10 bg-primary/10 rounded-full flex items-center justify-center text-primary">
<Terminal className="size-5" />
</div>
<div className="flex flex-col">
<h1 className="text-text dark:text-heading text-base font-bold leading-tight">console.svc.plus</h1>
<p className="text-primary text-xs font-medium uppercase tracking-wider">Eye-Care Mode</p>
</div>
</div>
{/* Nav Links */}
<nav className="flex flex-col gap-1">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors font-medium ${
pathname === item.href
? 'bg-primary/10 text-primary'
: 'text-text-muted hover:bg-background-muted'
}`}
>
<item.icon className="size-5" />
<span className="text-sm">{item.label}</span>
</Link>
))}
</nav>
</div>
<button className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-sm hover:opacity-90 transition-opacity">
<Plus className="size-5" />
<span>New Project</span>
</button>
</aside>
<SidebarRoot className="w-64 border-r border-surface-border p-6 bg-background dark:bg-background hidden md:flex justify-between">
<AppSidebarContent />
</SidebarRoot>
)
}

View File

@ -0,0 +1,488 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useLanguage } from "../i18n/LanguageProvider";
import { Menu, X, Sun, Moon, Monitor, Plus } from "lucide-react";
import { translations } from "../i18n/translations";
import LanguageToggle from "./LanguageToggle";
import { AskAIButton } from "./AskAIButton";
import ReleaseChannelSelector from "./ReleaseChannelSelector";
import { useUserStore } from "@lib/userStore";
import {
createNavConfig,
filterNavItems,
type NavItem,
type ReleaseChannel,
DEFAULT_CHANNELS,
RELEASE_CHANNEL_STORAGE_KEY,
CHANNEL_ORDER,
} from "@lib/navigation";
const getLabel = (
label: string | ((lang: string) => string),
lang: string,
): string => {
return typeof label === "function" ? label(lang) : label;
};
export default function UnifiedNavigation() {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [selectedChannels, setSelectedChannels] = useState<ReleaseChannel[]>([
"stable",
]);
const navRef = useRef<HTMLElement | null>(null);
const { language } = useLanguage();
const user = useUserStore((state) => state.user);
const nav = translations[language].nav;
const accountCopy = nav.account;
const accountInitial =
user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ??
"?";
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const isChinese = language === "zh";
useEffect(() => {
if (typeof window === "undefined") return;
const stored = window.localStorage.getItem(RELEASE_CHANNEL_STORAGE_KEY);
if (!stored) return;
try {
const parsed = JSON.parse(stored) as unknown;
if (!Array.isArray(parsed)) return;
const normalized = CHANNEL_ORDER.filter((channel) =>
parsed.includes(channel),
);
if (normalized.length === 0) return;
const restored: ReleaseChannel[] = normalized.includes("stable")
? normalized
: [...DEFAULT_CHANNELS, ...normalized];
setSelectedChannels((current) => {
if (
current.length === restored.length &&
current.every((value, index) => value === restored[index])
) {
return current;
}
return restored;
});
} catch (error) {
console.warn("Failed to restore release channels selection", error);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(
RELEASE_CHANNEL_STORAGE_KEY,
JSON.stringify(selectedChannels),
);
}, [selectedChannels]);
useEffect(() => {
if (!accountMenuOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
accountMenuRef.current &&
!accountMenuRef.current.contains(event.target as Node)
) {
setAccountMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [accountMenuOpen]);
useEffect(() => {
setAccountMenuOpen(false);
}, [user]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const element = navRef.current;
if (!element) {
return;
}
const updateOffset = () => {
const height = element.getBoundingClientRect().height;
document.documentElement.style.setProperty(
"--app-shell-nav-offset",
`${height}px`,
);
};
updateOffset();
const resizeObserver = new ResizeObserver(() => {
updateOffset();
});
resizeObserver.observe(element);
window.addEventListener("resize", updateOffset);
return () => {
window.removeEventListener("resize", updateOffset);
resizeObserver.disconnect();
};
}, []);
const toggleChannel = (channel: ReleaseChannel) => {
if (channel === "stable") return;
setSelectedChannels((prev) =>
prev.includes(channel)
? prev.filter((value) => value !== channel)
: [...prev, channel],
);
};
const { mainNav, secondaryNav, accountNav } = createNavConfig(
language,
!!user,
!!user?.isAdmin,
!!user?.isOperator,
);
const filteredMainNav = filterNavItems(mainNav, user);
const filteredSecondaryNav = filterNavItems(secondaryNav, user);
const isHiddenRoute = pathname
? [
"/login",
"/register",
"/xstream",
"/xcloudflow",
"/xscopehub",
"/blogs",
].some((prefix) => pathname.startsWith(prefix))
: false;
if (isHiddenRoute) {
return null;
}
const isActive = (item: NavItem): boolean => {
if (item.active) {
return item.active(pathname || "");
}
return pathname === item.href;
};
return (
<>
<nav
ref={navRef}
className="sticky top-0 z-50 w-full border-b border-surface-border bg-background/95 text-text backdrop-blur transition-colors duration-150"
>
<div className="lg:hidden flex items-center justify-between px-4 py-3 bg-background">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 -ml-2 rounded-xl bg-surface-muted hover:bg-surface-hover text-text transition-colors"
aria-label="Toggle menu"
>
{menuOpen ? (
<X className="w-5 h-5" />
) : (
<Menu className="w-5 h-5" />
)}
</button>
<div className="w-10" />
</div>
<div className="hidden lg:block mx-auto w-full max-w-7xl px-6 sm:px-8">
<div className="flex items-center gap-5 py-3">
<div className="flex flex-1 items-center gap-5">
<nav className="hidden items-center gap-2 text-sm font-medium text-text-muted lg:flex whitespace-nowrap">
{filteredMainNav.map((item) => {
const active = isActive(item);
if (item.showOn === "mobile") return null;
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
{getLabel(item.label, language)}
</span>
</Link>
);
})}
{filteredSecondaryNav.map((item) => {
const active = isActive(item);
if (item.showOn === "mobile") return null;
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-colors whitespace-nowrap ${active
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text hover:bg-surface-muted"
}`}
>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="text-[13px] tracking-tight">
{getLabel(item.label, language)}
</span>
</Link>
);
})}
</nav>
</div>
<div className="hidden flex-1 items-center justify-end gap-4 lg:flex">
{user ? (
<div className="relative" ref={accountMenuRef}>
<button
type="button"
onClick={() => setAccountMenuOpen((prev) => !prev)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-primary to-accent text-sm font-semibold text-white shadow-shadow-sm transition hover:from-primary-hover hover:to-accent focus:outline-none focus:ring-2 focus:ring-primary/60 focus:ring-offset-2 focus:ring-offset-background"
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
>
{accountInitial}
</button>
{accountMenuOpen ? (
<div className="absolute right-0 mt-2 w-56 overflow-hidden rounded-xl border border-surface-border bg-surface/95 shadow-shadow-md">
<div className="border-b border-surface-border bg-surface-muted px-4 py-3">
<p className="text-sm font-semibold text-text">
{user.username}
</p>
<p className="text-xs text-text-muted">{user.email}</p>
</div>
<div className="py-1 text-sm text-text">
{accountNav.map((item) => (
<Link
key={item.key}
href={item.href}
className="block px-4 py-2 text-sm opacity-80 transition hover:bg-primary/10 hover:opacity-100"
onClick={() => setAccountMenuOpen(false)}
>
{typeof item.label === "function"
? item.label(language)
: item.label}
</Link>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="flex items-center gap-3 text-sm font-medium text-text-muted">
<Link
href="/login"
className="text-sm opacity-80 transition hover:text-primary hover:opacity-100"
>
{nav.account.login}
</Link>
<span
className="h-3 w-px bg-surface-border"
aria-hidden="true"
/>
<Link
href="/register"
className="rounded-md border border-surface-border px-3 py-1 text-primary transition hover:border-primary/40 hover:bg-surface-muted"
>
{nav.account.register}
</Link>
</div>
)}
<LanguageToggle />
<ReleaseChannelSelector
selected={selectedChannels}
onToggle={toggleChannel}
variant="icon"
/>
</div>
</div>
</div>
{menuOpen && (
<div className="fixed inset-0 z-[60] lg:hidden">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute inset-y-0 left-0 w-80 max-w-[85vw] bg-background shadow-2xl transition-transform duration-300 ease-in-out">
<div className="flex h-full flex-col overflow-y-auto border-r border-surface-border">
<div className="flex items-center justify-between border-b border-surface-border p-4">
<Link
href="/"
className="flex items-center gap-2"
onClick={() => setMenuOpen(false)}
>
<Image
src="/icons/cloudnative_32.png"
alt="logo"
width={24}
height={24}
className="h-6 w-6"
unoptimized
/>
<span className="text-lg font-bold tracking-tight">
Cloud-Neutral
</span>
</Link>
<button
onClick={() => setMenuOpen(false)}
className="rounded-lg p-2 text-text-muted hover:bg-surface-muted transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{user && (
<div className="border-b border-surface-border bg-surface-muted/30 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-primary to-accent text-sm font-semibold text-white">
{accountInitial}
</div>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-semibold">
{user.username}
</p>
<p className="truncate text-xs text-text-muted">
{user.email}
</p>
</div>
</div>
</div>
)}
<div className="flex-1 p-4">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "主导航" : "Main Navigation"}
</p>
<div className="space-y-1 mb-6">
{filteredMainNav.map((item) => {
const active = isActive(item);
if (item.showOn === "desktop") return null;
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
</Link>
);
})}
</div>
{filteredSecondaryNav.length > 0 && (
<>
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "其他" : "Other"}
</p>
<div className="space-y-1 mb-6">
{filteredSecondaryNav.map((item) => {
const active = isActive(item);
if (item.showOn === "desktop") return null;
return (
<Link
key={item.key}
href={item.href}
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${active
? "bg-primary/10 text-primary"
: "text-text hover:bg-surface-muted"
}`}
onClick={() => setMenuOpen(false)}
>
{item.icon && (
<item.icon className="mr-3 h-5 w-5 opacity-70" />
)}
<span>
{getLabel(item.label, language)}
</span>
</Link>
);
})}
</div>
</>
)}
<div className="mt-8 space-y-3 px-2">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50 mb-2">
{isChinese ? "账户" : "Account"}
</p>
{accountNav.map((item) => (
<Link
key={item.key}
href={item.href}
className={`flex w-full items-center justify-center rounded-xl py-3 text-sm font-bold transition ${item.key === "logout"
? "bg-rose-500/10 text-rose-600 shadow-sm hover:bg-rose-500/20"
: "border border-surface-border bg-surface-muted/50 dark:bg-surface-muted/30 hover:bg-surface-hover"
}`}
onClick={() => setMenuOpen(false)}
>
{typeof item.label === "function"
? item.label(language)
: item.label}
</Link>
))}
</div>
</div>
<div className="border-t border-surface-border p-4 space-y-4">
<div className="flex flex-col gap-3">
<p className="px-2 text-[10px] font-bold uppercase tracking-widest text-text-muted opacity-50">
{isChinese ? "设置" : "Settings"}
</p>
<div className="flex items-center justify-between rounded-xl bg-surface-muted/50 p-2">
<span className="ml-2 text-xs font-medium text-text-muted">
{isChinese ? "界面语言" : "Language"}
</span>
<LanguageToggle />
</div>
<div className="flex flex-col gap-2 rounded-xl bg-surface-muted/50 p-2">
<span className="ml-2 text-xs font-medium text-text-muted mb-1">
{isChinese ? "发布频道" : "Channels"}
</span>
<ReleaseChannelSelector
selected={selectedChannels}
onToggle={toggleChannel}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</nav>
<div className="hidden lg:block">
<AskAIButton />
</div>
</>
);
}

View File

@ -0,0 +1,111 @@
'use client'
import React from 'react'
import { useLanguage } from '../../i18n/LanguageProvider'
import { SidebarHeader, SidebarContent } from '../layout/SidebarRoot'
const sidebarContent = {
zh: {
title: '文档与社区',
sections: [
{
slug: 'docs',
title: '文档与教程',
items: [
{ label: '产品文档', description: '按模块拆分的接口与操作指南。', href: '#' },
{ label: '快速入门', description: '5 分钟完成首个项目的配置与发布。', href: '#' },
{ label: '操作指南', description: '常见任务的分步操作示例。', href: '#' },
],
},
{
slug: 'practices',
title: '最佳实践',
items: [
{ label: 'GitOps 标准化', description: '流水线模板、审批与回滚策略。', href: '#' },
{ label: 'IaC 资产管理', description: 'Terraform 与 Pulumi 资产治理指引。', href: '#' },
{ label: '多云治理手册', description: '跨区域合规、网络隔离与零信任。', href: '#' },
],
},
{
slug: 'community',
title: '社区与支持',
items: [
{ label: '发布说明', description: '版本更新、缺陷修复与补丁计划。', href: '#' },
{ label: '社区日程', description: 'Workshop、Live Demo 与用户群活动。', href: '#' },
{ label: '加入讨论', description: '提交 Issue 或加入 Slack/微信群。', href: '#' },
],
},
],
},
en: {
title: 'Documentation & Community',
sections: [
{
slug: 'docs',
title: 'Docs & Tutorials',
items: [
{ label: 'Product Docs', description: 'Module-specific API and operations guides.', href: '#' },
{ label: 'Quickstart', description: 'Configure and ship your first project in minutes.', href: '#' },
{ label: 'How-to Guides', description: 'Step-by-step recipes for recurring tasks.', href: '#' },
],
},
{
slug: 'practices',
title: 'Best Practices',
items: [
{ label: 'GitOps Playbooks', description: 'Pipeline templates, approvals, and rollbacks.', href: '#' },
{ label: 'IaC Governance', description: 'Guidance for Terraform and Pulumi asset control.', href: '#' },
{ label: 'Multi-Cloud Handbook', description: 'Compliance, network isolation, and Zero Trust.', href: '#' },
],
},
{
slug: 'community',
title: 'Community & Support',
items: [
{ label: 'Release Notes', description: 'Updates, fixes, and patch availability.', href: '#' },
{ label: 'Community Calendar', description: 'Workshops, live demos, and user groups.', href: '#' },
{ label: 'Join the Conversation', description: 'File issues or join Slack/WeChat.', href: '#' },
],
},
],
},
}
export function HomeSidebarContent() {
const { language } = useLanguage()
const data = sidebarContent[language as keyof typeof sidebarContent]
return (
<>
<SidebarHeader className="space-y-1 mb-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{data.title}</p>
<p className="text-[13px] leading-relaxed text-slate-600">
{language === 'zh'
? '查阅文档、最佳实践与社区讨论,保持交付与治理同频。'
: 'Stay aligned with docs, practices, and community conversations.'}
</p>
</SidebarHeader>
<SidebarContent className="space-y-4">
{data.sections.map((section) => (
<div key={section.slug} className="space-y-2 rounded-md border border-black/10 bg-[#f6f7f9] p-3">
<h3 className="text-sm font-semibold text-slate-900">{section.title}</h3>
<ul className="space-y-2">
{section.items.map((item) => (
<li key={item.label} className="group flex flex-col gap-1">
<a
href={item.href}
className="text-sm font-semibold text-[#3467e9] transition hover:text-[#2957cf]"
>
{item.label}
</a>
{item.description && <p className="text-[12px] text-slate-600">{item.description}</p>}
</li>
))}
</ul>
</div>
))}
</SidebarContent>
</>
)
}

View File

@ -1,107 +1,13 @@
'use client'
import { useLanguage } from '../../i18n/LanguageProvider'
const sidebarContent = {
zh: {
title: '文档与社区',
sections: [
{
slug: 'docs',
title: '文档与教程',
items: [
{ label: '产品文档', description: '按模块拆分的接口与操作指南。', href: '#' },
{ label: '快速入门', description: '5 分钟完成首个项目的配置与发布。', href: '#' },
{ label: '操作指南', description: '常见任务的分步操作示例。', href: '#' },
],
},
{
slug: 'practices',
title: '最佳实践',
items: [
{ label: 'GitOps 标准化', description: '流水线模板、审批与回滚策略。', href: '#' },
{ label: 'IaC 资产管理', description: 'Terraform 与 Pulumi 资产治理指引。', href: '#' },
{ label: '多云治理手册', description: '跨区域合规、网络隔离与零信任。', href: '#' },
],
},
{
slug: 'community',
title: '社区与支持',
items: [
{ label: '发布说明', description: '版本更新、缺陷修复与补丁计划。', href: '#' },
{ label: '社区日程', description: 'Workshop、Live Demo 与用户群活动。', href: '#' },
{ label: '加入讨论', description: '提交 Issue 或加入 Slack/微信群。', href: '#' },
],
},
],
},
en: {
title: 'Documentation & Community',
sections: [
{
slug: 'docs',
title: 'Docs & Tutorials',
items: [
{ label: 'Product Docs', description: 'Module-specific API and operations guides.', href: '#' },
{ label: 'Quickstart', description: 'Configure and ship your first project in minutes.', href: '#' },
{ label: 'How-to Guides', description: 'Step-by-step recipes for recurring tasks.', href: '#' },
],
},
{
slug: 'practices',
title: 'Best Practices',
items: [
{ label: 'GitOps Playbooks', description: 'Pipeline templates, approvals, and rollbacks.', href: '#' },
{ label: 'IaC Governance', description: 'Guidance for Terraform and Pulumi asset control.', href: '#' },
{ label: 'Multi-Cloud Handbook', description: 'Compliance, network isolation, and Zero Trust.', href: '#' },
],
},
{
slug: 'community',
title: 'Community & Support',
items: [
{ label: 'Release Notes', description: 'Updates, fixes, and patch availability.', href: '#' },
{ label: 'Community Calendar', description: 'Workshops, live demos, and user groups.', href: '#' },
{ label: 'Join the Conversation', description: 'File issues or join Slack/WeChat.', href: '#' },
],
},
],
},
}
import React from 'react'
import { SidebarRoot } from '../layout/SidebarRoot'
import { HomeSidebarContent } from './HomeSidebarContent'
export default function Sidebar() {
const { language } = useLanguage()
const data = sidebarContent[language]
return (
<aside className="flex h-full w-full flex-col gap-5 rounded-lg border border-black/10 bg-white p-5 text-slate-800">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{data.title}</p>
<p className="text-[13px] leading-relaxed text-slate-600">
{language === 'zh'
? '查阅文档、最佳实践与社区讨论,保持交付与治理同频。'
: 'Stay aligned with docs, practices, and community conversations.'}
</p>
</div>
<div className="space-y-4">
{data.sections.map((section) => (
<div key={section.slug} className="space-y-2 rounded-md border border-black/10 bg-[#f6f7f9] p-3">
<h3 className="text-sm font-semibold text-slate-900">{section.title}</h3>
<ul className="space-y-2">
{section.items.map((item) => (
<li key={item.label} className="group flex flex-col gap-1">
<a
href={item.href}
className="text-sm font-semibold text-[#3467e9] transition hover:text-[#2957cf]"
>
{item.label}
</a>
{item.description && <p className="text-[12px] text-slate-600">{item.description}</p>}
</li>
))}
</ul>
</div>
))}
</div>
</aside>
<SidebarRoot className="w-full rounded-lg border border-black/10 bg-white p-5 text-slate-800">
<HomeSidebarContent />
</SidebarRoot>
)
}

View File

@ -0,0 +1,47 @@
'use client'
import React from 'react'
import { cn } from '@/lib/utils'
interface SidebarRootProps {
children: React.ReactNode
className?: string
}
/**
* SidebarRoot - The base skeleton for all sidebars.
* Provides the container and common layout behavior.
*/
export function SidebarRoot({ children, className }: SidebarRootProps) {
return (
<aside
className={cn(
"flex h-full flex-col bg-background transition-colors duration-300",
className
)}
>
{children}
</aside>
)
}
/**
* SidebarHeader - Top section of the sidebar (e.g., Branding, Logo).
*/
export function SidebarHeader({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex-shrink-0", className)}>{children}</div>
}
/**
* SidebarContent - Middle scrollable section of the sidebar.
*/
export function SidebarContent({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex-1 overflow-y-auto min-h-0", className)}>{children}</div>
}
/**
* SidebarFooter - Bottom fixed section of the sidebar (e.g., User, Settings, Call to Action).
*/
export function SidebarFooter({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("mt-auto flex-shrink-0", className)}>{children}</div>
}

181
src/lib/navigation.ts Normal file
View File

@ -0,0 +1,181 @@
"use client";
import type { LucideIcon } from "lucide-react";
import {
MessageSquare,
BarChart2,
Link as LinkIcon,
Server,
LayoutDashboard,
Rocket,
Database,
Key,
History,
Settings,
Plus,
Home,
FileText,
Book,
Info,
} from "lucide-react";
export type ReleaseChannel = "stable" | "beta" | "develop";
export type NavItem = {
key: string;
label: string | ((lang: string) => string);
href: string;
icon?: LucideIcon | ((props: any) => React.ReactNode);
active?: (pathname: string) => boolean;
channels?: ReleaseChannel[];
enabled?: boolean;
badge?: string;
children?: NavItem[];
showOn?: "desktop" | "mobile" | "both";
requireAuth?: boolean;
requireAdmin?: boolean;
requireOperator?: boolean;
};
export const CHANNEL_ORDER: ReleaseChannel[] = ["stable", "beta", "develop"];
export const DEFAULT_CHANNELS: ReleaseChannel[] = ["stable"];
export const RELEASE_CHANNEL_STORAGE_KEY = "cloudnative-suite.releaseChannels";
export const getLabel = (
label: string | ((lang: string) => string),
lang: string,
): string => {
return typeof label === "function" ? label(lang) : label;
};
export const createNavConfig = (
language: string,
isLoggedIn: boolean,
isAdmin: boolean,
isOperator: boolean,
): {
mainNav: NavItem[];
secondaryNav: NavItem[];
accountNav: NavItem[];
} => {
const isChinese = language === "zh";
const mainNav: NavItem[] = [
{
key: "home",
label: isChinese ? "首页" : "Home",
href: "/",
icon: Home,
active: (pathname) => pathname === "/",
showOn: "both",
},
{
key: "chat",
label: (lang) => (lang === "zh" ? "AI 助手" : "AI Assistant"),
href: "/services/moltbot/chats",
icon: MessageSquare,
active: (pathname) => pathname?.startsWith("/services/moltbot"),
showOn: "both",
},
{
key: "docs",
label: isChinese ? "文档" : "Docs",
href: "/docs",
icon: FileText,
active: (pathname) => pathname?.startsWith("/docs"),
showOn: "both",
},
{
key: "console",
label: isChinese ? "控制台" : "Console",
href: "/panel",
icon: LayoutDashboard,
active: (pathname) => pathname.startsWith("/panel") && !pathname.startsWith("/panel/management"),
showOn: "both",
},
{
key: "services",
label: isChinese ? "更多服务" : "More Services",
href: "/services",
icon: Plus,
active: (pathname) => pathname.startsWith("/services") && !pathname.startsWith("/services/moltbot"),
showOn: "both",
},
{
key: "about",
label: isChinese ? "关于" : "About",
href: "/about",
icon: Info,
active: (pathname) => pathname === "/about",
showOn: "both",
},
];
const secondaryNav: NavItem[] = [
{
key: "management",
label: isChinese ? "实例管理" : "Instances",
href: "/panel/management",
icon: Server,
active: (pathname) => pathname === "/panel/management",
showOn: "both",
requireAdmin: true,
requireOperator: true,
},
];
const accountNav: NavItem[] = isLoggedIn
? [
{
key: "userCenter",
label: isChinese ? "用户中心" : "User Center",
href: "/panel",
icon: BarChart2,
showOn: "both",
},
...(isAdmin || isOperator
? [
{
key: "management",
label: isChinese ? "管理" : "Management",
href: "/panel/management",
icon: Settings,
showOn: "both" as const,
},
]
: []),
{
key: "logout",
label: isChinese ? "退出登录" : "Logout",
href: "/logout",
showOn: "both",
badge: isChinese ? "退出" : "Logout",
},
]
: [
{
key: "register",
label: isChinese ? "注册" : "Register",
href: "/register",
showOn: "both",
},
{
key: "login",
label: isChinese ? "登录" : "Login",
href: "/login",
showOn: "both",
},
];
return { mainNav, secondaryNav, accountNav };
};
export const filterNavItems = (items: NavItem[], user: any): NavItem[] => {
return items.filter((item) => {
if (item.requireAuth && !user) return false;
if (item.requireAdmin && !user?.isAdmin) return false;
if (item.requireOperator && !user?.isOperator) return false;
if (item.enabled === false) return false;
return true;
});
};

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}