feat(cms): 添加 CMS 内容管理系统与 Admin 管理后台

- 新增 Prisma + SQLite 数据库模型 (Category, Content, Media, User 等)
- 新增 Admin 管理后台 (认证、内容管理、媒体管理)
- 新增 CMS API 路由 (CRUD, 草稿/发布, 重新验证)
- 新增 CMS 内容版本的历史归档页面
- 新增 components/cms 内容渲染组件
- 新增 components/admin 管理后台 UI 组件
- 更新 Contact API 路由
This commit is contained in:
张翔
2026-07-07 06:53:58 +08:00
parent 829d83522c
commit b5245f9aa2
87 changed files with 25659 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from './auth-context';
import {
LayoutDashboard,
FileText,
Briefcase,
Box,
Layers,
Image,
BarChart3,
Newspaper,
LogOut,
Menu,
X,
ChevronDown,
Settings,
Users,
Phone,
Shield,
} from 'lucide-react';
const menuItems = [
{
label: '内容管理',
icon: FileText,
children: [
{ label: '新闻资讯', href: '/admin/content/news', icon: Newspaper },
{ label: '案例研究', href: '/admin/content/case-study', icon: Briefcase },
{ label: '服务管理', href: '/admin/content/service', icon: Settings },
{ label: '产品管理', href: '/admin/content/product', icon: Box },
{ label: '解决方案', href: '/admin/content/solution', icon: Layers },
{ label: 'Hero Banner', href: '/admin/content/hero-banner', icon: Image },
{ label: '数据指标', href: '/admin/content/stat-item', icon: BarChart3 },
],
},
{
label: '页面管理',
icon: FileText,
children: [
{ label: '关于我们', href: '/admin/content/about-page', icon: Users },
{ label: '团队介绍', href: '/admin/content/team-page', icon: Users },
{ label: '联系我们', href: '/admin/content/contact-page', icon: Phone },
{ label: '法律页面', href: '/admin/content/legal-page', icon: Shield },
],
},
{
label: '页面配置',
icon: Layers,
children: [
{ label: '首页区域', href: '/admin/zones', icon: LayoutDashboard },
],
},
{
label: '资源管理',
icon: Image,
children: [
{ label: '媒体库', href: '/admin/media', icon: Image },
],
},
];
export function AdminLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set(['内容管理']));
const { user, logout } = useAuth();
const pathname = usePathname();
const router = useRouter();
const toggleMenu = (label: string) => {
setExpandedMenus((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
};
const handleLogout = () => {
logout();
router.push('/admin/login');
};
const isActive = (href: string) => pathname === href || pathname?.startsWith(href + '/');
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform duration-200 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<Link href="/admin" className="flex items-center gap-2">
<span className="text-lg font-bold text-gray-900">Novalon CMS</span>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded hover:bg-gray-100"
>
<X className="w-5 h-5" />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{/* Dashboard */}
<Link
href="/admin"
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
pathname === '/admin'
? 'bg-gray-900 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<LayoutDashboard className="w-4 h-4" />
</Link>
{/* Menu groups */}
{menuItems.map((group) => (
<div key={group.label}>
<button
onClick={() => toggleMenu(group.label)}
className="flex items-center justify-between w-full px-3 py-2.5 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors"
>
<span className="flex items-center gap-3">
<group.icon className="w-4 h-4" />
{group.label}
</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${
expandedMenus.has(group.label) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedMenus.has(group.label) && (
<div className="ml-4 mt-1 space-y-1">
{group.children.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive(item.href)
? 'bg-gray-900 text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
<item.icon className="w-3.5 h-3.5" />
{item.label}
</Link>
))}
</div>
)}
</div>
))}
</nav>
{/* User info */}
<div className="border-t border-gray-200 p-3">
<div className="flex items-center gap-3 px-3 py-2">
<div className="w-8 h-8 rounded-full bg-gray-900 text-white flex items-center justify-center text-sm font-medium">
{user?.nickname?.charAt(0) || 'A'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{user?.nickname || user?.username}
</p>
<p className="text-xs text-gray-500">{user?.role === 'admin' ? '管理员' : '编辑'}</p>
</div>
<button
onClick={handleLogout}
className="p-1.5 rounded hover:bg-gray-100 text-gray-400 hover:text-red-500 transition-colors"
title="退出登录"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</aside>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-30 h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 lg:px-6">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 rounded hover:bg-gray-100"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex-1" />
<div className="flex items-center gap-4">
<Link
href="/"
target="_blank"
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
</Link>
</div>
</header>
{/* Page content */}
<main className="p-4 lg:p-6">{children}</main>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
interface AuthUser {
id: string;
username: string;
nickname: string;
role: string;
avatar: string;
}
interface AuthContextType {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>({
user: null,
token: null,
loading: true,
login: async () => {},
logout: () => {},
});
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const savedToken = localStorage.getItem('novalon_admin_token');
const savedUser = localStorage.getItem('novalon_admin_user');
if (savedToken && savedUser) {
try {
setToken(savedToken);
setUser(JSON.parse(savedUser));
} catch {
localStorage.removeItem('novalon_admin_token');
localStorage.removeItem('novalon_admin_user');
}
}
setLoading(false);
}, []);
const login = useCallback(async (username: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '登录失败');
}
const data = await res.json();
setToken(data.accessToken);
setUser(data.user);
localStorage.setItem('novalon_admin_token', data.accessToken);
localStorage.setItem('novalon_admin_user', JSON.stringify(data.user));
}, []);
const logout = useCallback(() => {
setToken(null);
setUser(null);
localStorage.removeItem('novalon_admin_token');
localStorage.removeItem('novalon_admin_user');
router.push('/admin/login');
}, [router]);
return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
+473
View File
@@ -0,0 +1,473 @@
'use client';
import { useState, useMemo, useEffect, useCallback, createElement } from 'react';
import {
getAllContentModels,
getItemRenderer,
ContentZoneRenderer,
getContentItems,
saveContentItem,
getContentZone,
} from '@/lib/cms';
import type { ContentModel, ContentItem, FieldDefinition } from '@/lib/cms';
import { RichTextEditor } from './RichTextEditor';
const PREVIEW_ZONES: { key: string; name: string }[] = [
{ key: 'home-stats', name: '首页 · 数据指标区' },
{ key: 'home-services', name: '首页 · 核心服务区' },
{ key: 'home-solutions', name: '首页 · 解决方案区' },
{ key: 'home-cases', name: '首页 · 客户案例区' },
{ key: 'home-news', name: '首页 · 新闻动态区' },
];
function FieldEditor({ field, value, onChange }: {
field: FieldDefinition;
value: unknown;
onChange: (val: unknown) => void;
}) {
const baseClass = 'w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors';
switch (field.type) {
case 'text':
return (
<input
type="text"
className={baseClass}
value={(value as string) || ''}
placeholder={field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'textarea':
return (
<textarea
className={`${baseClass} min-h-[100px] resize-y`}
value={(value as string) || ''}
placeholder={field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'richtext':
return (
<RichTextEditor
value={(value as string) || ''}
onChange={onChange}
placeholder={field.label}
/>
);
case 'number':
return (
<input
type="number"
className={baseClass}
value={(value as number) ?? ''}
placeholder={field.label}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : 0)}
/>
);
case 'boolean':
return (
<label className="inline-flex items-center gap-3 cursor-pointer">
<div
className={`relative w-11 h-6 rounded-full transition-colors ${value ? 'bg-brand' : 'bg-white/10'}`}
onClick={() => onChange(!value)}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`}
/>
</div>
<span className="text-sm text-white/70">{field.label}</span>
</label>
);
case 'date':
return (
<input
type="date"
className={baseClass}
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'select':
case 'dropdown':
return (
<select
className={baseClass}
value={String(value ?? '')}
onChange={(e) => {
const opt = field.options?.find((o) => String(o.value) === e.target.value);
onChange(opt ? opt.value : e.target.value);
}}
>
<option value="">...</option>
{field.options?.map((opt) => (
<option key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</option>
))}
</select>
);
case 'image': {
const imgUrl = typeof value === 'string' ? value : '';
return (
<div className="space-y-2">
{imgUrl && (
<div className="aspect-video rounded-lg overflow-hidden bg-white/5 border border-white/10">
<img src={imgUrl} alt="" className="w-full h-full object-cover" />
</div>
)}
<input
type="text"
className={baseClass}
value={imgUrl}
placeholder="图片 URL"
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
case 'array':
return (
<div className="space-y-2">
{(Array.isArray(value) ? value : []).map((item: unknown, idx: number) => (
<div key={idx} className="flex gap-2">
<input
type="text"
className={baseClass}
value={typeof item === 'string' ? item : item && typeof item === 'object' ? String((item as Record<string, unknown>).name || (item as Record<string, unknown>).text || '') : ''}
placeholder={`${idx + 1}`}
onChange={(e) => {
const newArr = [...(Array.isArray(value) ? value : [])];
if (field.fields?.[0]?.name) {
newArr[idx] = { ...(item as object), [field.fields[0].name]: e.target.value };
} else {
newArr[idx] = e.target.value;
}
onChange(newArr);
}}
/>
<button
onClick={() => {
const newArr = [...(Array.isArray(value) ? value : [])];
newArr.splice(idx, 1);
onChange(newArr);
}}
className="px-3 text-red-400 hover:text-red-300 text-sm"
>
×
</button>
</div>
))}
<button
onClick={() => {
const newItem = field.fields?.[0]?.name ? { [field.fields[0].name]: '' } : '';
onChange([...(Array.isArray(value) ? value : []), newItem]);
}}
className="text-sm text-brand hover:text-brand/80 font-medium"
>
+
</button>
</div>
);
default:
return (
<input
type="text"
className={baseClass}
value={(value as string) || ''}
placeholder={field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
export default function ContentEditor() {
const [models, setModels] = useState<ContentModel[]>([]);
const [selectedModelCode, setSelectedModelCode] = useState<string>('case-study');
const [items, setItems] = useState<ContentItem[]>([]);
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
const [editData, setEditData] = useState<Record<string, unknown>>({});
const [previewZoneKey, setPreviewZoneKey] = useState('home-cases');
const loadItems = useCallback(() => {
const list = getContentItems(selectedModelCode);
setItems(list);
if (list.length > 0) {
const current = list.find((i) => i.id === selectedItem?.id);
if (current) {
setSelectedItem(current);
} else if (!selectedItem) {
setSelectedItem(list[0] || null);
setEditData({ ...(list[0]?.data || {}) });
}
} else {
setSelectedItem(null);
setEditData({});
}
}, [selectedModelCode, selectedItem]);
useEffect(() => {
setModels(getAllContentModels());
}, []);
useEffect(() => {
loadItems();
const handleCmsUpdate = () => loadItems();
window.addEventListener('cms-updated', handleCmsUpdate);
return () => window.removeEventListener('cms-updated', handleCmsUpdate);
}, [loadItems]);
const selectedModel = useMemo(
() => models.find((m) => m.code === selectedModelCode),
[models, selectedModelCode]
);
const handleFieldChange = (fieldName: string, value: unknown) => {
const newData = { ...editData, [fieldName]: value };
setEditData(newData);
if (selectedItem) {
const updated = { ...selectedItem, data: newData, title: fieldName === 'title' ? (value as string) : selectedItem.title };
setSelectedItem(updated);
}
};
const handleSave = () => {
if (!selectedItem) return;
const saved = { ...selectedItem, data: editData };
saveContentItem(selectedItem.modelCode, saved);
};
const handleSelectItem = (item: ContentItem) => {
setSelectedItem(item);
setEditData({ ...item.data });
};
const PreviewRenderer = useMemo(
() => (selectedItem ? getItemRenderer(selectedItem.modelCode) : null),
[selectedItem]
);
const previewZone = useMemo(() => {
const zoneData = getContentZone(previewZoneKey);
if (!zoneData || !selectedItem) return null;
const updatedItems = zoneData.items.map((zi) => {
if (zi.item?.id === selectedItem.id) {
return { ...zi, item: { ...selectedItem, data: editData } };
}
return zi;
});
return { ...zoneData, items: updatedItems };
}, [previewZoneKey, selectedItem, editData]);
return (
<div className="h-full flex">
<div className="w-80 flex-shrink-0 border-r border-white/[0.08] overflow-y-auto">
<div className="p-4 border-b border-white/[0.08]">
<label className="block text-xs font-medium text-white/60 mb-2"></label>
<select
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
value={selectedModelCode}
onChange={(e) => {
setSelectedModelCode(e.target.value);
setSelectedItem(null);
}}
>
{models.map((m) => (
<option key={m.code} value={m.code}>
{m.name}
</option>
))}
</select>
</div>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-white/50 font-medium"></span>
<button className="text-xs text-brand hover:text-brand/80 font-medium">
+
</button>
</div>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
onClick={() => handleSelectItem(item)}
className={`p-3 rounded-lg cursor-pointer transition-all border ${
selectedItem?.id === item.id
? 'bg-brand/10 border-brand/30'
: 'bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.05] hover:border-white/[0.1]'
}`}
>
<div className="text-sm font-medium text-white line-clamp-1">
{item.title}
</div>
<div className="mt-1 text-xs text-white/40 flex items-center gap-2">
<span>{item.slug}</span>
<span className="w-1 h-1 rounded-full bg-emerald-400" />
<span className="text-emerald-400"></span>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex-1 flex min-h-0">
<div className="flex-1 overflow-y-auto p-6">
{selectedItem ? (
<div className="max-w-2xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-1 text-sm text-white/40"></p>
</div>
<button
onClick={handleSave}
className="px-4 py-2 bg-brand hover:bg-brand-hover text-white text-sm font-medium rounded-lg transition-colors"
>
💾
</button>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">
</label>
<input
type="text"
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors"
value={selectedItem.title}
onChange={(e) =>
setSelectedItem({ ...selectedItem, title: e.target.value })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">
SlugURL
</label>
<input
type="text"
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm font-mono focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors"
value={selectedItem.slug}
onChange={(e) =>
setSelectedItem({ ...selectedItem, slug: e.target.value })
}
/>
</div>
<div className="border-t border-white/[0.08] pt-5">
<h3 className="text-sm font-semibold text-white/80 mb-4"></h3>
<div className="space-y-5">
{selectedModel?.fields?.map((field) => (
<div key={field.name}>
<label className="block text-sm font-medium text-white/80 mb-2">
{field.label}
{field.required && (
<span className="text-red-400 ml-1">*</span>
)}
</label>
<FieldEditor
field={field}
value={editData[field.name]}
onChange={(val) => handleFieldChange(field.name, val)}
/>
{field.description && (
<p className="mt-1.5 text-xs text-white/35">
{field.description}
</p>
)}
</div>
))}
{!selectedModel?.fields?.length && (
<div className="text-sm text-white/40 py-8 text-center">
</div>
)}
</div>
</div>
<div className="border-t border-white/[0.08] pt-5">
<h3 className="text-sm font-semibold text-white/80 mb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-white/60 mb-2"></label>
<select
value={selectedItem.status}
onChange={(e) =>
setSelectedItem({
...selectedItem,
status: e.target.value as ContentItem['status'],
})
}
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
>
<option value="published"></option>
<option value="draft">稿</option>
<option value="archived"></option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-2"></label>
<input
type="number"
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
value={selectedItem.sortOrder ?? 0}
onChange={(e) =>
setSelectedItem({
...selectedItem,
sortOrder: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="text-5xl mb-4 opacity-30">📝</div>
<div className="text-white/50"></div>
</div>
</div>
)}
</div>
<div className="w-[420px] flex-shrink-0 border-l border-white/[0.08] overflow-y-auto bg-ink">
<div className="p-4 border-b border-white/[0.08] bg-ink-light flex items-center justify-between">
<span className="text-xs font-medium text-white/60"></span>
<select
className="px-2 py-1 bg-white/[0.05] border border-white/[0.1] rounded-md text-white text-xs focus:outline-none"
value={previewZoneKey}
onChange={(e) => setPreviewZoneKey(e.target.value)}
>
<option value="single"></option>
{PREVIEW_ZONES.map((z) => (
<option key={z.key} value={z.key}>
{z.name}
</option>
))}
</select>
</div>
<div className="p-4">
{previewZoneKey === 'single' && selectedItem && PreviewRenderer
? createElement(PreviewRenderer, { item: { ...selectedItem, data: editData } })
: previewZone ? (
<ContentZoneRenderer zone={previewZone} />
) : (
<div className="text-sm text-white/40 py-8 text-center">
</div>
)}
</div>
</div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import type { ContentItem } from '@/lib/cms';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const componentRegistry = new Map<string, React.ComponentType<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerComponent(modelCode: string, Component: React.ComponentType<any>) {
componentRegistry.set(modelCode, Component);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getRegisteredComponent(modelCode: string): React.ComponentType<any> | undefined {
return componentRegistry.get(modelCode);
}
export interface ContentRendererProps {
item: ContentItem;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component?: React.ComponentType<any>;
fallback?: React.ReactNode;
}
export function ContentRenderer({ item, component: ComponentProp, fallback = null }: ContentRendererProps) {
const Component = React.useMemo(() => {
return ComponentProp || getRegisteredComponent(item.modelCode);
}, [ComponentProp, item.modelCode]);
if (!Component) {
return <>{fallback}</>;
}
return React.createElement(Component, { item, data: item.data });
}
+207
View File
@@ -0,0 +1,207 @@
'use client';
import * as React from 'react';
import type { FieldDefinition, FieldType } from '@/lib/cms';
export interface FieldRendererProps {
field: FieldDefinition;
value: unknown;
className?: string;
}
export function FieldRenderer({ field, value, className }: FieldRendererProps) {
if (value === undefined || value === null) {
return null;
}
const renderByType = (type: FieldType, val: unknown, fieldDef: FieldDefinition): React.ReactNode => {
switch (type) {
case 'text':
case 'textarea':
return <span className={className}>{String(val)}</span>;
case 'richtext':
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: String(val) }}
/>
);
case 'number':
return <span className={className}>{formatNumber(val)}</span>;
case 'boolean':
return <span className={className}>{val ? '是' : '否'}</span>;
case 'date':
return <span className={className}>{formatDate(val, 'date')}</span>;
case 'datetime':
return <span className={className}>{formatDate(val, 'datetime')}</span>;
case 'image':
case 'media':
return renderMedia(val, fieldDef, className);
case 'reference':
return <span className={className}>{String(val)}</span>;
case 'references':
if (Array.isArray(val)) {
return (
<div className={className}>
{val.map((item, index) => (
<span key={index}>{String(item)}</span>
))}
</div>
);
}
return null;
case 'object':
return renderObject(val, fieldDef, className);
case 'array':
return renderArray(val, fieldDef, className);
case 'json':
return (
<pre className={className}>
{JSON.stringify(val, null, 2)}
</pre>
);
default:
return <span className={className}>{String(val)}</span>;
}
};
return <>{renderByType(field.type, value, field)}</>;
}
function formatNumber(value: unknown): string {
const num = Number(value);
if (isNaN(num)) return String(value);
return num.toLocaleString('zh-CN');
}
function formatDate(value: unknown, format: 'date' | 'datetime'): string {
const date = new Date(String(value));
if (isNaN(date.getTime())) return String(value);
if (format === 'date') {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function renderMedia(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
if (typeof value === 'string') {
return (
<img
src={value}
alt={field.label}
className={className}
loading="lazy"
/>
);
}
if (typeof value === 'object' && value !== null) {
const media = value as Record<string, unknown>;
const url = (media.url || media.path || media.src) as string | undefined;
const alt = (media.alt || field.label) as string;
if (url) {
return (
<img
src={url}
alt={alt}
className={className}
loading="lazy"
width={typeof media.width === 'number' ? media.width : undefined}
height={typeof media.height === 'number' ? media.height : undefined}
/>
);
}
}
return null;
}
function renderObject(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
if (!field.fields || !value || typeof value !== 'object') {
return null;
}
const obj = value as Record<string, unknown>;
return (
<div className={className}>
{field.fields.map((subField) => (
<div key={subField.name} className="mb-2">
<span className="font-medium text-text-secondary">{subField.label}</span>
<FieldRenderer field={subField} value={obj[subField.name]} />
</div>
))}
</div>
);
}
function renderArray(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
if (!Array.isArray(value)) {
return null;
}
const itemFields = field.fields;
if (itemFields && itemFields.length > 0) {
return (
<div className={className}>
{value.map((item, index) => (
<div key={index} className="mb-3">
<div className="font-medium text-text-secondary mb-1"> {index + 1} </div>
{renderArrayItem(item, itemFields)}
</div>
))}
</div>
);
}
return (
<ul className={className}>
{value.map((item, index) => (
<li key={index}>{String(item)}</li>
))}
</ul>
);
}
function renderArrayItem(item: unknown, fields: FieldDefinition[]): React.ReactNode {
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>;
return (
<div className="pl-4 border-l-2 border-border-primary">
{fields.map((subField) => (
<div key={subField.name} className="mb-1">
<span className="text-sm text-text-muted">{subField.label}</span>
<FieldRenderer field={subField} value={obj[subField.name]} />
</div>
))}
</div>
);
}
return <span>{String(item)}</span>;
}
+204
View File
@@ -0,0 +1,204 @@
'use client';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import {
Bold,
Italic,
Heading2,
Heading3,
List,
ListOrdered,
Link as LinkIcon,
Quote,
Undo2,
Redo2,
} from 'lucide-react';
interface RichTextEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
}
/**
* TipTap 富文本编辑器
* 用于 CMS ContentEditor 的 richtext 字段
* 支持:加粗、斜体、标题、列表、引用、链接
* 暗色主题适配(CMS Studio 暗色背景)
*/
export function RichTextEditor({ value, onChange, placeholder = '请输入内容...' }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-brand underline',
},
}),
Placeholder.configure({
placeholder,
}),
],
content: value,
immediatelyRender: false,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
editorProps: {
attributes: {
class: 'rich-text-editor-content',
},
},
});
// 同步外部 value 变化(如切换内容条目时)
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [editor, value]);
if (!editor) {
return (
<div className="min-h-[120px] bg-white/[0.05] border border-white/[0.1] rounded-lg p-3 text-white/40 text-sm">
...
</div>
);
}
return (
<div className="rich-text-editor bg-white/[0.05] border border-white/[0.1] rounded-lg overflow-hidden focus-within:border-brand/50 focus-within:ring-1 focus-within:ring-brand/30 transition-colors">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
);
}
function Toolbar({ editor }: { editor: Editor }) {
const btnClass = (isActive: boolean) =>
`p-1.5 rounded transition-colors ${
isActive
? 'bg-brand/20 text-brand'
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
}`;
const setLink = () => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('链接 URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
return (
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-white/[0.08] bg-white/[0.02]">
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className={btnClass(editor.isActive('bold'))}
title="加粗"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={btnClass(editor.isActive('italic'))}
title="斜体"
>
<Italic className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={btnClass(editor.isActive('heading', { level: 2 }))}
title="标题 2"
>
<Heading2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={btnClass(editor.isActive('heading', { level: 3 }))}
title="标题 3"
>
<Heading3 className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={btnClass(editor.isActive('bulletList'))}
title="无序列表"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={btnClass(editor.isActive('orderedList'))}
title="有序列表"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={btnClass(editor.isActive('blockquote'))}
title="引用"
>
<Quote className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-white/[0.08] mx-1" />
<button
type="button"
onClick={setLink}
className={btnClass(editor.isActive('link'))}
title="链接"
>
<LinkIcon className="w-4 h-4" />
</button>
<div className="flex-1" />
<button
type="button"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="撤销"
>
<Undo2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="重做"
>
<Redo2 className="w-4 h-4" />
</button>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import { ContentRenderer } from './ContentRenderer';
import { Spinner } from '@/components/ui/loading-state';
import type { ContentZone, ContentItem } from '@/lib/cms';
import { cmsClient } from '@/lib/cms';
export interface SectionRendererProps {
zoneCode: string;
fallback?: React.ReactNode;
loadingFallback?: React.ReactNode;
className?: string;
}
export function SectionRenderer({
zoneCode,
fallback = null,
loadingFallback,
className,
}: SectionRendererProps) {
const [zone, setZone] = React.useState<ContentZone | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
let mounted = true;
async function fetchZone() {
try {
setIsLoading(true);
setError(null);
if (cmsClient?.getZone) {
const result = await cmsClient.getZone(zoneCode);
if (mounted) {
setZone(result);
}
} else {
if (mounted) {
setZone(null);
}
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mounted) {
setIsLoading(false);
}
}
}
fetchZone();
return () => {
mounted = false;
};
}, [zoneCode]);
if (isLoading) {
if (loadingFallback) {
return <>{loadingFallback}</>;
}
return (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
);
}
if (error || !zone) {
return <>{fallback}</>;
}
const sortedItems = [...zone.items].sort((a, b) => a.sortOrder - b.sortOrder);
return (
<div className={className}>
{sortedItems.map((zoneItem, index) => {
if (!zoneItem.item) return null;
return (
<ContentRenderer
key={zoneItem.itemId || index}
item={zoneItem.item as ContentItem}
fallback={null}
/>
);
})}
</div>
);
}
+394
View File
@@ -0,0 +1,394 @@
'use client';
import { useState, useMemo, useEffect, useCallback } from 'react';
import {
ContentZoneRenderer,
getContentZone,
getAllContentZones,
getContentItems,
reorderZoneItems,
removeItemFromZone,
addItemToZone,
updateZoneSettings,
resetCmsData,
exportCmsData,
} from '@/lib/cms';
import type { ContentZone, ContentItem } from '@/lib/cms';
const ZONE_META: Record<string, { name: string; page: string }> = {
'home-stats': { name: '首页 · 数据指标区', page: 'home' },
'home-services': { name: '首页 · 核心服务区', page: 'home' },
'home-solutions': { name: '首页 · 解决方案区', page: 'home' },
'home-cases': { name: '首页 · 客户案例区', page: 'home' },
'home-news': { name: '首页 · 新闻动态区', page: 'home' },
};
export function ZoneManager() {
const [zoneKey, setZoneKey] = useState('home-cases');
const [zone, setZone] = useState<ContentZone | null>(null);
const [showAddPanel, setShowAddPanel] = useState(false);
const [showTools, setShowTools] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [zoneList, setZoneList] = useState<Record<string, ContentZone>>({});
const loadZones = useCallback(() => {
setZoneList(getAllContentZones());
const z = getContentZone(zoneKey);
setZone(z);
}, [zoneKey]);
useEffect(() => {
loadZones();
const handleCmsUpdate = () => loadZones();
window.addEventListener('cms-updated', handleCmsUpdate);
return () => window.removeEventListener('cms-updated', handleCmsUpdate);
}, [loadZones]);
const zoneItemModelCode = useMemo(() => {
return zone?.items?.[0]?.item?.modelCode || 'case-study';
}, [zone]);
const candidateItems = useMemo(() => {
if (!zone) return [];
const allItems = getContentItems(zoneItemModelCode);
const existingIds = new Set(zone.items?.map((zi) => zi.item?.id).filter(Boolean) || []);
return allItems.filter((item) => !existingIds.has(item.id));
}, [zone, zoneItemModelCode]);
const sortedItems = useMemo(() => {
return [...(zone?.items || [])]
.filter((zi) => zi.item)
.sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
);
}, [zone]);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDrop = (targetIndex: number) => {
if (dragIndex === null || dragIndex === targetIndex) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
reorderZoneItems(zoneKey, dragIndex, targetIndex);
setDragIndex(null);
setDragOverIndex(null);
};
const handleRemoveItem = (itemId: string) => {
removeItemFromZone(zoneKey, itemId);
};
const handleAddItem = (item: ContentItem) => {
addItemToZone(zoneKey, item);
setShowAddPanel(false);
};
const handleLayoutChange = (field: string, value: unknown) => {
updateZoneSettings(zoneKey, { [field]: value });
};
const handleReset = () => {
if (confirm('确定要重置所有 CMS 数据为默认值吗?此操作不可撤销。')) {
resetCmsData();
}
};
const handleExport = () => {
const data = exportCmsData();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `novalon-cms-export-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const zoneMeta = ZONE_META[zoneKey];
const zoneKeys = Object.keys(zoneList).sort();
return (
<div className="h-full flex">
<div className="w-72 flex-shrink-0 border-r border-white/[0.08] overflow-y-auto flex flex-col">
<div className="p-4 flex-1">
<div className="text-xs text-white/40 font-medium uppercase tracking-wider mb-3 px-3">
</div>
<div className="space-y-1">
{zoneKeys.map((key) => (
<button
key={key}
onClick={() => setZoneKey(key)}
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
zoneKey === key
? 'bg-brand/20 text-brand font-medium'
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
}`}
>
<div className="font-medium">{ZONE_META[key]?.name || key}</div>
<div className="text-xs opacity-60 mt-0.5">
{zoneList[key]?.items?.length || 0}
</div>
</button>
))}
</div>
</div>
<div className="p-3 border-t border-white/[0.08] space-y-2">
<button
onClick={() => setShowTools(!showTools)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
>
<span> </span>
<span>{showTools ? '▲' : '▼'}</span>
</button>
{showTools && (
<div className="space-y-1 px-2">
<button
onClick={handleExport}
className="w-full text-left px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
>
📤
</button>
<button
onClick={handleReset}
className="w-full text-left px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition-colors"
>
🔄
</button>
</div>
)}
</div>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex min-h-0">
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">
{zoneMeta?.name || zoneKey}
</h2>
<p className="mt-1 text-sm text-white/40">
· × · {sortedItems.length}
</p>
</div>
<button
onClick={() => setShowAddPanel(true)}
className="px-4 py-2 bg-brand hover:bg-brand-hover text-white text-sm font-medium rounded-lg transition-colors"
>
+
</button>
</div>
<div className="space-y-3">
{sortedItems.map((zoneItem, index) => {
const item = zoneItem.item!;
return (
<div
key={item.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={() => {
setDragIndex(null);
setDragOverIndex(null);
}}
className={`
relative flex items-center gap-4 p-4 rounded-xl border transition-all cursor-move
${dragOverIndex === index ? 'border-brand/50 bg-brand/5' : 'border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.04]'}
${dragIndex === index ? 'opacity-40' : ''}
`}
>
<div className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-white/[0.05] text-white/40 text-sm">
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">
{item.title}
</div>
<div className="mt-0.5 text-xs text-white/40">
{item.modelCode} · {item.slug}
</div>
</div>
<div className="flex-shrink-0 text-xs text-white/40">
#{index + 1}
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveItem(item.id);
}}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
×
</button>
</div>
);
})}
{sortedItems.length === 0 && (
<div className="py-16 text-center border-2 border-dashed border-white/[0.08] rounded-xl">
<div className="text-4xl mb-3 opacity-30">📦</div>
<div className="text-white/50 text-sm"></div>
</div>
)}
</div>
</div>
<div className="w-80 flex-shrink-0 border-l border-white/[0.08] overflow-y-auto">
<div className="p-4 border-b border-white/[0.08]">
<h3 className="text-sm font-semibold text-white"></h3>
</div>
<div className="p-4 space-y-5">
<div>
<label className="block text-xs font-medium text-white/60 mb-2">
</label>
<select
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
value={(zone?.settings?.layout as string) || 'grid'}
onChange={(e) => handleLayoutChange('layout', e.target.value)}
>
<option value="grid"></option>
<option value="list"></option>
<option value="carousel"></option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-2">
</label>
<select
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
value={(zone?.settings?.columns as number) || 3}
onChange={(e) => handleLayoutChange('columns', Number(e.target.value))}
>
<option value={1}>1 </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={5}>5 </option>
<option value={6}>6 </option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/60 mb-2">
</label>
<select
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
value={(zone?.settings?.gap as string) || 'medium'}
onChange={(e) => handleLayoutChange('gap', e.target.value)}
>
<option value="small"></option>
<option value="medium"></option>
<option value="large"></option>
</select>
</div>
<div className="border-t border-white/[0.08] pt-5">
<h4 className="text-xs font-medium text-white/60 mb-3"></h4>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-white/40">Zone Key</span>
<span className="text-white/70 font-mono text-xs">{zoneKey}</span>
</div>
<div className="flex justify-between">
<span className="text-white/40"></span>
<span className="text-white/70">{zoneMeta?.page || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-white/40"></span>
<span className="text-white/70">{zoneItemModelCode}</span>
</div>
<div className="flex justify-between">
<span className="text-white/40"></span>
<span className="text-white/70">{sortedItems.length}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="h-64 flex-shrink-0 border-t border-white/[0.08] bg-ink overflow-y-auto">
<div className="p-4 border-b border-white/[0.08] bg-ink-light flex items-center justify-between">
<span className="text-xs font-medium text-white/60"></span>
<span className="text-xs text-white/40">
: {(zone?.settings?.layout as string) || 'grid'} · {(zone?.settings?.columns as number) || 3}
</span>
</div>
<div className="p-6">
{zone ? (
<ContentZoneRenderer zone={zone} />
) : (
<div className="text-sm text-white/40 py-8 text-center">
</div>
)}
</div>
</div>
</div>
{showAddPanel && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={() => setShowAddPanel(false)}
>
<div
className="bg-ink border border-white/[0.1] rounded-2xl w-full max-w-lg max-h-[70vh] overflow-hidden shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-white/[0.08] flex items-center justify-between">
<h3 className="text-base font-semibold text-white"></h3>
<button
onClick={() => setShowAddPanel(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors"
>
×
</button>
</div>
<div className="p-4 overflow-y-auto max-h-[60vh]">
{candidateItems.length > 0 ? (
<div className="space-y-2">
{candidateItems.map((item) => (
<button
key={item.id}
onClick={() => handleAddItem(item)}
className="w-full text-left p-3 rounded-lg border border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.06] hover:border-brand/30 transition-all"
>
<div className="text-sm font-medium text-white">{item.title}</div>
<div className="mt-0.5 text-xs text-white/40">
{item.modelCode} · {item.slug}
</div>
</button>
))}
</div>
) : (
<div className="py-12 text-center">
<div className="text-4xl mb-3 opacity-30"></div>
<div className="text-white/50 text-sm"></div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { ContentRenderer, registerComponent, getRegisteredComponent } from './ContentRenderer';
export type { ContentRendererProps } from './ContentRenderer';
export { SectionRenderer } from './SectionRenderer';
export type { SectionRendererProps } from './SectionRenderer';
export { FieldRenderer } from './FieldRenderer';
export type { FieldRendererProps } from './FieldRenderer';
+391
View File
@@ -0,0 +1,391 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { CheckCircle2, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
export interface MethodologyLayer {
title: string;
description: string;
items: string[];
}
export interface MethodologyFrameworkProps {
title: string;
subtitle?: string;
layers: MethodologyLayer[];
accentColor?: string;
}
export function MethodologyFramework({
title,
subtitle,
layers,
accentColor = '#C41E3A',
}: MethodologyFrameworkProps) {
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Methodology</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="relative">
<StaggerReveal staggerDelay={0.1} delayChildren={0.05}>
{layers.map((layer, index) => (
<motion.div
key={index}
className="relative group"
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE_OUT }}
>
<div className="flex gap-6 lg:gap-10">
<div className="flex flex-col items-center">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold text-lg shrink-0"
style={{ backgroundColor: accentColor }}
>
{String(index + 1).padStart(2, '0')}
</div>
{index < layers.length - 1 && (
<div className="w-px flex-1 bg-gradient-to-b from-border-primary to-transparent mt-3" />
)}
</div>
<div className="flex-1 pb-12 lg:pb-16">
<h3 className="text-xl lg:text-2xl font-bold text-text-primary mb-3">
{layer.title}
</h3>
<p className="text-text-secondary leading-relaxed mb-5">
{layer.description}
</p>
<div className="grid sm:grid-cols-2 gap-3">
{layer.items.map((item, i) => (
<div key={i} className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 shrink-0 mt-0.5" style={{ color: accentColor }} />
<span className="text-text-secondary text-sm leading-relaxed">
{item}
</span>
</div>
))}
</div>
</div>
</div>
</motion.div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
export interface TechStackCategory {
name: string;
items: string[];
}
export interface TechStackShowcaseProps {
title: string;
subtitle?: string;
categories: TechStackCategory[];
}
export function TechStackShowcase({ title, subtitle, categories }: TechStackShowcaseProps) {
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Tech Stack</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
{categories.map((category, index) => (
<div
key={index}
className="group relative p-8 border border-border-primary bg-bg-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-500"
>
<div className="absolute top-0 left-0 h-1 w-0 group-hover:w-full transition-all duration-700 bg-brand" />
<h3 className="text-lg font-bold text-text-primary mb-5">{category.name}</h3>
<div className="flex flex-wrap gap-2">
{category.items.map((item, i) => (
<span
key={i}
className="px-3 py-1.5 text-xs font-medium bg-bg-secondary text-text-secondary border border-border-primary hover:border-brand/30 hover:text-brand transition-colors duration-300"
>
{item}
</span>
))}
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
export interface TimelineStep {
phase: string;
title: string;
description: string;
duration: string;
deliverables: string[];
}
export interface DeliveryTimelineProps {
title: string;
subtitle?: string;
steps: TimelineStep[];
accentColor?: string;
}
export function DeliveryTimeline({
title,
subtitle,
steps,
accentColor = '#C41E3A',
}: DeliveryTimelineProps) {
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Delivery Process</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="relative">
<div className="absolute left-8 lg:left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-brand/50 via-border-primary to-brand/50" />
<div className="space-y-12 lg:space-y-16">
{steps.map((step, index) => (
<ScrollReveal key={index} delay={index * 0.08}>
<div className={cn(
'relative flex gap-8 lg:gap-12',
index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'
)}>
<div className="hidden lg:block lg:w-1/2" />
<div className="absolute left-8 lg:left-1/2 -translate-x-1/2 w-4 h-4 rounded-full border-2 z-10"
style={{ borderColor: accentColor, backgroundColor: '#ffffff' }}
/>
<div className="lg:w-1/2 pl-16 lg:pl-0">
<div className={cn(
'p-8 lg:p-10 border border-border-primary bg-bg-secondary hover:border-border-secondary transition-all duration-500',
index % 2 === 0 ? 'lg:ml-12' : 'lg:mr-12'
)}>
<div className="flex items-center justify-between mb-4">
<span
className="text-xs font-bold tracking-[0.2em] uppercase"
style={{ color: accentColor }}
>
{step.phase}
</span>
<span className="text-sm text-text-muted">
{step.duration}
</span>
</div>
<h3 className="text-xl font-bold text-text-primary mb-3">{step.title}</h3>
<p className="text-text-secondary leading-relaxed mb-5">
{step.description}
</p>
<div className="pt-5 border-t border-border-primary">
<div className="text-xs text-text-muted mb-3 font-medium uppercase tracking-wider">
</div>
<div className="flex flex-wrap gap-2">
{step.deliverables.map((deliverable, i) => (
<span
key={i}
className="px-2.5 py-1 text-xs bg-bg-primary text-text-secondary border border-border-primary"
>
{deliverable}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
);
}
export interface FAQItem {
question: string;
answer: string;
}
export interface FAQSectionProps {
title: string;
subtitle?: string;
items: FAQItem[];
}
export function FAQSection({ title, subtitle, items }: FAQSectionProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="grid lg:grid-cols-12 gap-12 lg:gap-20">
<ScrollReveal className="lg:col-span-4">
<SectionLabel>FAQ</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="lg:col-span-8 space-y-3">
<StaggerReveal staggerDelay={0.06} delayChildren={0.05}>
{items.map((item, index) => (
<div
key={index}
className="border border-border-primary bg-bg-primary hover:border-border-secondary transition-colors duration-300"
>
<button
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full flex items-center justify-between gap-6 p-6 lg:p-7 text-left"
>
<span className="text-text-primary font-semibold text-base lg:text-lg">
{item.question}
</span>
<motion.div
animate={{ rotate: openIndex === index ? 180 : 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="shrink-0"
>
<ChevronDown className="w-5 h-5 text-text-muted" />
</motion.div>
</button>
<motion.div
initial={false}
animate={{
height: openIndex === index ? 'auto' : 0,
opacity: openIndex === index ? 1 : 0,
}}
transition={{ duration: 0.4, ease: EASE_OUT }}
className="overflow-hidden"
>
<div className="px-6 lg:px-7 pb-7 pt-1 text-text-secondary leading-relaxed">
{item.answer}
</div>
</motion.div>
</div>
))}
</StaggerReveal>
</div>
</div>
</div>
</section>
);
}
export interface DataMetric {
value: string;
label: string;
description?: string;
trend?: string;
trendUp?: boolean;
}
export interface DataProofSectionProps {
title: string;
subtitle?: string;
metrics: DataMetric[];
}
export function DataProofSection({ title, subtitle, metrics }: DataProofSectionProps) {
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Proven Results</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary">
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
{metrics.map((metric, index) => (
<div
key={index}
className="group relative p-10 lg:p-12 bg-bg-secondary hover:bg-bg-secondary transition-all duration-500"
>
<div className="absolute top-0 left-0 h-1 w-0 group-hover:w-full transition-all duration-700 bg-brand" />
<div className="relative z-10">
<div className="text-5xl lg:text-6xl font-bold tracking-tighter mb-4">
<span className="text-text-primary">{metric.value}</span>
</div>
<div className="text-lg font-semibold text-text-primary mb-2">
{metric.label}
</div>
{metric.description && (
<p className="text-sm text-text-muted leading-relaxed">
{metric.description}
</p>
)}
{metric.trend && (
<div className={cn(
'mt-4 inline-flex items-center gap-1.5 text-sm font-medium',
metric.trendUp ? 'text-success' : 'text-brand'
)}>
<span>{metric.trend}</span>
</div>
)}
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { Quote } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
export interface Testimonial {
quote: string;
author: string;
title: string;
company: string;
industry: string;
}
export interface TestimonialSectionProps {
title: string;
subtitle?: string;
testimonials: Testimonial[];
accentColor?: string;
}
export function TestimonialSection({
title,
subtitle,
testimonials,
accentColor = '#C41E3A',
}: TestimonialSectionProps) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
<SectionLabel>Client Stories</SectionLabel>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
{title}
</h2>
{subtitle && (
<p className="text-text-secondary leading-relaxed text-lg">
{subtitle}
</p>
)}
</ScrollReveal>
<div className="relative">
<div className="relative overflow-hidden">
<motion.div
className="flex"
animate={{ x: `-${activeIndex * 100}%` }}
transition={{ duration: 0.6, ease: EASE_OUT }}
>
{testimonials.map((testimonial, index) => (
<div key={index} className="w-full shrink-0">
<div className="max-w-4xl mx-auto">
<div className="relative p-10 lg:p-16 border border-border-primary bg-bg-primary">
<Quote className="absolute top-8 left-8 w-12 h-12 opacity-10 text-brand" />
<div className="relative z-10">
<p className="text-xl lg:text-2xl text-text-primary leading-relaxed font-light mb-10 lg:mb-14">
{testimonial.quote}
</p>
<div className="flex items-center gap-5">
<div
className="w-14 h-14 rounded-full flex items-center justify-center text-white font-bold text-lg"
style={{ backgroundColor: accentColor }}
>
{testimonial.author.charAt(0)}
</div>
<div>
<div className="text-text-primary font-semibold text-lg">
{testimonial.author}
</div>
<div className="text-text-secondary text-sm">
{testimonial.title} · {testimonial.company}
</div>
<div className="text-text-muted text-xs mt-0.5">
{testimonial.industry}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</motion.div>
</div>
<div className="flex justify-center gap-3 mt-10">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={cn(
'h-2 rounded-full transition-all duration-500',
activeIndex === index
? 'w-8'
: 'w-2 bg-border-primary hover:bg-border-secondary'
)}
style={activeIndex === index ? { backgroundColor: accentColor } : {}}
/>
))}
</div>
</div>
</div>
</section>
);
}
export interface LogoWallProps {
title: string;
logos: { name: string; industry?: string }[];
}
export function LogoWall({ title, logos }: LogoWallProps) {
return (
<section className="relative py-16 sm:py-20 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal>
<div className="text-center mb-12">
<p className="text-sm text-text-muted tracking-[0.2em] uppercase font-medium">
{title}
</p>
</div>
</ScrollReveal>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-px bg-border-primary">
{logos.map((logo, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.05, ease: EASE_OUT }}
className="flex items-center justify-center p-8 bg-bg-primary hover:bg-bg-secondary transition-colors duration-300"
>
<div className="text-center">
<div className="text-text-secondary font-semibold text-base hover:text-text-primary transition-colors duration-300">
{logo.name}
</div>
{logo.industry && (
<div className="text-text-muted text-xs mt-1">
{logo.industry}
</div>
)}
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}