portal/src/app/services/page.tsx
google-labs-jules[bot] 32ea4c509a feat: Implement Material 3 Layout with Theme Switcher
- Implemented a new Material 3-inspired layout for the services page, following the user's design reference.
- Added a theme switcher to toggle between the original ("classic") and the new ("material") layouts.
- The theme preference is persisted in `localStorage` using a Zustand store.
- Ensured proper server-side rendering and client-side hydration by creating a custom `useViewStore` hook that prevents hydration mismatches.
- Created new components for the Material 3 layout: `Sidebar`, `Header`, and `Material3Layout`.
- Refactored the main services page (`src/app/services/page.tsx`) to conditionally render either the classic or material layout based on the selected theme.
- Moved the theme switcher to the `Footer` component to make it accessible in both views.
- Added a Playwright test to verify the functionality of the theme switcher.
2026-01-25 11:29:03 +00:00

176 lines
8.2 KiB
TypeScript

'use client'
import Link from 'next/link'
import {
Activity,
ArrowRight,
BookOpen,
FileText,
Layers,
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'
const placeholderCount = 3
type ServiceCardData = {
key: string
name: string
description: string
href: string
icon: typeof Activity
external?: boolean
}
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="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'
}`}>
<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>
</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'}
<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">
{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 placeholderDescription = isChinese
? '预留卡片位置,持续扩充入口。'
: '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="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'
}`}>
<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>
<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 }) => {
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} />
))}
{Array.from({ length: placeholderCount }).map((_, index) => (
<PlaceholderCard key={`placeholder-${index}`} view={view} isChinese={isChinese} />
))}
</section>
)
}
export default function ServicesPage() {
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, },
]
if (!isHydrated) {
return null
}
if (view === 'material') {
return (
<Material3Layout>
<div className="mb-10">
<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.
</p>
</div>
<ServiceGrid view="material" services={services} isChinese={isChinese} />
</Material3Layout>
)
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div
className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.18),transparent_35%),radial-gradient(circle_at_80%_0,rgba(168,85,247,0.15),transparent_30%),radial-gradient(circle_at_50%_60%,rgba(52,211,153,0.08),transparent_35%)]"
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 pb-20">
<Navbar />
<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'}
</p>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">
{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.'}
</p>
</header>
<ServiceGrid view="classic" services={services} isChinese={isChinese} />
</main>
<Footer />
</div>
</div>
)
}