feat: refactor navigation and sidebar structure, implement AI Assistant layout modes
This commit is contained in:
parent
b0fc695e4f
commit
e5c616c12a
160
skills/ui.engineering.principles.v1.md
Normal file
160
skills/ui.engineering.principles.v1.md
Normal file
@ -0,0 +1,160 @@
|
||||
|
||||
UI Engineering Principles v1(for 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/slots(Header/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 默认向后兼容
|
||||
|
||||
任何改动尽量不破坏旧 API(props/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 在结束前必须自检并明确回答(是/否):
|
||||
|
||||
是否减少了重复,而不是引入更多分支?
|
||||
|
||||
是否让新增需求更容易通过组合实现?
|
||||
|
||||
是否状态更少、更近、更可测?
|
||||
|
||||
是否可覆盖样式并保持语义化?
|
||||
|
||||
是否可回滚(原子提交/分阶段)?
|
||||
|
||||
任意一项为“否”,必须解释原因或调整实现。
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
216
src/app/components/insight/layout/InsightSidebarContent.tsx
Normal file
216
src/app/components/insight/layout/InsightSidebarContent.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
233
src/app/docs/DocsSidebarContent.tsx
Normal file
233
src/app/docs/DocsSidebarContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
256
src/app/page.tsx
256
src/app/page.tsx
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
210
src/app/panel/components/PanelSidebarContent.tsx
Normal file
210
src/app/panel/components/PanelSidebarContent.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
70
src/components/AppSidebarContent.tsx
Normal file
70
src/components/AppSidebarContent.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
488
src/components/UnifiedNavigation.tsx
Normal file
488
src/components/UnifiedNavigation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
src/components/home/HomeSidebarContent.tsx
Normal file
111
src/components/home/HomeSidebarContent.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
47
src/components/layout/SidebarRoot.tsx
Normal file
47
src/components/layout/SidebarRoot.tsx
Normal 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
181
src/lib/navigation.ts
Normal 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
6
src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user