Files
freedakgmail e9822f5c92 0.9.0.0
2025-12-22 07:52:55 +08:00

1410 lines
68 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import type React from "react"
import Link from "next/link"
import Image from "next/image"
import dynamic from "next/dynamic"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ChineseCard, ChineseCardContent, ChineseCardHeader, ChineseCardTitle, ChineseCardDescription, ChineseDivider } from "@/components/ui/chinese-card"
import { Dialog, DialogContent, DialogTitle, DialogHeader, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { SiteHeader } from "@/components/site-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3, Calculator, Loader2, Images, ChevronDown, ChevronUp, EyeOff, Play, Video, Edit } from "lucide-react"
import { useFamily } from "@/context/family-context"
import { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import { format } from "date-fns"
import { useSession } from "next-auth/react"
import { useSearchParams } from "next/navigation"
import { CollaboratorDialog } from "@/components/tree/collaborator-dialog"
import { permissions } from "@/lib/permissions"
import { isInCurrentMonth, solar2lunar, lunar2solar } from "@/lib/lunar-calendar"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import type { FamilyPhoto } from "@/types/family"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { PhotoCard } from "@/components/dashboard/photo-card"
// 动态导入统计图表组件(减少初始加载体积)
const StatisticsCharts = dynamic(
() => import("@/components/dashboard/statistics-charts").then(mod => ({ default: mod.StatisticsCharts })),
{
ssr: false,
loading: () => (
<div className="w-full h-64 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">...</span>
</div>
)
}
)
interface ActivityLog {
id: string
action: string
entityType: string
entityId?: string | null
entityName?: string | null
changes?: any
timestamp: string
user?: {
name?: string | null
email?: string | null
}
}
// 判断是否为视频文件
const isVideoFile = (url: string) => {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
}
// 计算事件日期的辅助函数
const calculateEventDate = (
dateStr: string,
isLunar: boolean,
now: Date
): { date: Date; lunarDisplay?: string } | null => {
const date = new Date(dateStr)
let eventDate: Date
let lunarDisplay: string | undefined
if (isLunar) {
const lunarInfo = solar2lunar(date)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
eventDate = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
} else {
eventDate = new Date(now.getFullYear(), date.getMonth(), date.getDate())
}
return { date: eventDate, lunarDisplay }
}
// 创建即将到来的事件
const createUpcomingEvent = (
member: any,
type: 'birth' | 'death',
dateStr: string,
isLunar: boolean,
now: Date,
threeMonthsLater: Date
) => {
const result = calculateEventDate(dateStr, isLunar, now)
if (!result) return null
const { date, lunarDisplay } = result
if (date >= now && date <= threeMonthsLater) {
return {
member,
type,
date,
originalDate: dateStr,
isLunar,
lunarDisplay,
month: date.getMonth() + 1,
day: date.getDate()
}
}
return null
}
export default function DashboardPage() {
const { treeData, isLoading, currentTree, updateMember } = useFamily()
const { data: session } = useSession()
const { showAlert } = useDialog()
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
const [statsExpanded, setStatsExpanded] = useState(true)
const [recentActivityExpanded, setRecentActivityExpanded] = useState(false)
const [adminToggleLoading, setAdminToggleLoading] = useState<string | null>(null)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editForm, setEditForm] = useState({ name: '', description: '' })
const [isSaving, setIsSaving] = useState(false)
const router = useRouter()
// 首次登录跳转到使用帮助
useEffect(() => {
if (!session?.user?.id) return
if (session.user.hasSeenHelp) return
router.replace('/help')
}, [router, session?.user?.id, session?.user?.hasSeenHelp])
// 加载最近的操作日志
useEffect(() => {
// 只有在有家族树且已登录时才获取活动日志
if (!currentTree?.id || !session?.user?.id) {
setRecentActivities([])
return
}
const controller = new AbortController()
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(data => {
if (data.logs && Array.isArray(data.logs)) {
setRecentActivities(data.logs)
}
})
.catch(err => {
// 忽略 abort 错误
if (err.name !== 'AbortError') {
console.error('获取活动日志失败:', err)
}
setRecentActivities([])
})
return () => controller.abort()
}, [currentTree?.id, session?.user?.id])
// 计算统计数据 - 单次遍历优化
const stats = useMemo(() => {
const members = Object.values(treeData.members)
const totalMembers = members.length
// 单次遍历计算所有统计数据
let livingMembers = 0
let deceasedMembers = 0
let maleCount = 0
let femaleCount = 0
let maxGeneration = 0
const birthYears: number[] = []
let totalAge = 0
let deceasedWithAgeCount = 0
members.forEach(m => {
// 生死统计
if (m.deathDate) {
deceasedMembers++
} else {
livingMembers++
}
// 性别统计
if (m.gender === 'MALE') maleCount++
else if (m.gender === 'FEMALE') femaleCount++
// 代数统计
if (m.generation && m.generation > maxGeneration) {
maxGeneration = m.generation
}
// 出生年份和寿命统计
if (m.birthDate) {
const birthYear = new Date(m.birthDate).getFullYear()
birthYears.push(birthYear)
if (m.deathDate) {
const deathYear = new Date(m.deathDate).getFullYear()
totalAge += deathYear - birthYear
deceasedWithAgeCount++
}
}
})
const earliestYear = birthYears.length > 0
? Math.min(...birthYears)
: new Date().getFullYear()
const yearsSpan = birthYears.length > 0
? new Date().getFullYear() - earliestYear
: 0
const averageLifespan = deceasedWithAgeCount > 0
? Math.round(totalAge / deceasedWithAgeCount)
: 0
return {
totalMembers,
livingMembers,
deceasedMembers,
maleCount,
femaleCount,
maxGeneration,
yearsSpan,
earliestYear,
averageLifespan
}
}, [treeData.members])
// 获取最近的成员(按ID排序,取最新的5个)
const recentMembers = useMemo(() => {
return Object.values(treeData.members)
.sort((a, b) => parseInt(b.id) - parseInt(a.id))
.slice(0, 5)
}, [treeData.members])
// 收集所有成员的照片
const isOwner = session && currentTree?.ownerId === session.user?.id
const allPhotos = useMemo(() => {
const photos: Array<{
url: string
caption?: string
uploadedAt: string
memberId: string
memberName: string
isDead: boolean
adminVisibleOverride: boolean
visibleInOverview: boolean
}> = []
Object.values(treeData.members).forEach(member => {
if (member.photos && member.photos.length > 0) {
(member.photos as FamilyPhoto[]).forEach(photo => {
const visible = photo.visibleInOverview ?? false
if (!visible) return
const adminAllowed = photo.adminVisibleOverride ?? true
if (!adminAllowed && !isOwner) return
photos.push({
url: photo.url,
caption: photo.caption,
uploadedAt: photo.uploadedAt,
memberId: member.id,
memberName: member.fullName,
isDead: !!member.deathDate,
adminVisibleOverride: adminAllowed,
visibleInOverview: visible,
})
})
}
})
return photos.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
}, [treeData.members, isOwner])
const groupedPhotosByMonth = useMemo(() => {
const sortedPhotos = [...allPhotos].sort((a, b) =>
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
)
const groupedByMonth: Record<string, typeof allPhotos> = {}
sortedPhotos.forEach(photo => {
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月')
if (!groupedByMonth[monthKey]) {
groupedByMonth[monthKey] = []
}
groupedByMonth[monthKey].push(photo)
})
return Object.entries(groupedByMonth)
}, [allPhotos])
const handleAdminPhotoToggle = useCallback(async (memberId: string, photoUrl: string, value: boolean) => {
if (!isOwner) return
const member = treeData.members[memberId]
if (!member || !member.photos || member.photos.length === 0) return
const key = `${memberId}|${photoUrl}`
setAdminToggleLoading(key)
try {
const updatedPhotos = (member.photos as FamilyPhoto[]).map(photo =>
photo.url === photoUrl ? { ...photo, adminVisibleOverride: value } : photo
)
await updateMember(memberId, { photos: updatedPhotos })
} catch (error) {
console.error('更新管理员展示权限失败:', error)
} finally {
setAdminToggleLoading(null)
}
}, [isOwner, updateMember])
// 保存编辑
const handleSaveEdit = async () => {
if (!currentTree?.id || !editForm.name.trim()) return
setIsSaving(true)
try {
const response = await fetch(`/api/trees/${currentTree.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: editForm.name.trim(),
description: editForm.description.trim() || null,
}),
})
if (!response.ok) {
throw new Error('更新失败')
}
setEditDialogOpen(false)
// 刷新页面以显示更新后的数据
window.location.reload()
} catch (error) {
console.error('更新家族信息失败:', error)
await showAlert('更新失败,请重试', "错误")
} finally {
setIsSaving(false)
}
}
// 获取本月纪念日(生日和忌日)- 支持农历
const monthlyAnniversaries = useMemo(() => {
const members = Object.values(treeData.members)
const anniversaries: Array<{
member: any
type: 'birth' | 'death'
date: string
day: number
month: number
isLunar: boolean
lunarDisplay?: string
}> = []
members.forEach(member => {
// 检查生日
if (member.birthDate) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.birthDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.birthDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'birth',
date: member.birthDate,
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
// 检查忌日
if (member.deathDate) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.deathDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.deathDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'death',
date: member.deathDate,
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
})
// 按日期排序
const sorted = anniversaries.sort((a, b) => a.day - b.day)
return sorted
}, [treeData.members])
// 计算未来三月纪念日 - 优化版本
const upcomingEvents = useMemo(() => {
const now = new Date()
const threeMonthsLater = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
const members = Object.values(treeData.members)
const events: Array<{
member: any
type: 'birth' | 'death'
date: Date
originalDate: string
isLunar: boolean
lunarDisplay?: string
month: number
day: number
}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthEvent = createUpcomingEvent(
member,
'birth',
member.birthDate,
member.isLunarDate || false,
now,
threeMonthsLater
)
if (birthEvent) events.push(birthEvent)
}
// 忌日
if (member.deathDate) {
const deathEvent = createUpcomingEvent(
member,
'death',
member.deathDate,
member.isLunarDate || false,
now,
threeMonthsLater
)
if (deathEvent) events.push(deathEvent)
}
})
return events.sort((a, b) => a.date.getTime() - b.date.getTime())
}, [treeData.members])
// 计算家族迁徙记录
const locationGroups = useMemo(() => {
const members = Object.values(treeData.members)
const groups: Record<string, any[]> = {}
members.forEach(member => {
if (member.ancestralHome) {
if (!groups[member.ancestralHome]) {
groups[member.ancestralHome] = []
}
groups[member.ancestralHome].push(member)
}
})
return Object.entries(groups).sort((a, b) => b[1].length - a[1].length)
}, [treeData.members])
// 如果用户没有家族树,显示欢迎页面
if (!isLoading && !currentTree) {
return (
<div className="flex min-h-screen flex-col bg-background font-sans">
<SiteHeader />
<main className="flex-1 flex items-center justify-center px-4 md:px-6 relative -mt-24">
{/* 背景装饰图片 */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* 文字图片 - 左上角,轻微向右倾斜 */}
<div className="absolute left-32 top-[20%] w-[20rem] h-[20rem] opacity-20 rotate-3">
<Image
src="/wxz.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片1 - 右上角,轻微向左倾斜,对称布局 */}
<div className="absolute right-32 top-[20%] w-[20rem] h-[20rem] opacity-20 -rotate-3">
<Image
src="/tree1.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 建筑图片 - 左下角,向右倾斜,营造动感 */}
<div className="absolute left-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 rotate-6">
<Image
src="/building.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片 - 右下角,向左倾斜,与建筑对称 */}
<div className="absolute right-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 -rotate-6">
<Image
src="/tree.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
</div>
{/* 内容区域 */}
<div className="max-w-3xl mx-auto text-center space-y-12">
{/* Slogan */}
<p className="text-2xl md:text-3xl text-foreground font-serif font-medium tracking-wider">
·
</p>
{/* 图标 */}
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/20">
<BookOpen className="h-16 w-16" strokeWidth={1} />
</div>
{/* 标题 */}
<div className="space-y-6">
<h1 className="text-4xl md:text-5xl font-serif font-light text-foreground tracking-wide">
</h1>
{/* 哲理文字 - 滚动显示 */}
<div className="h-48 overflow-hidden relative">
<div className="space-y-8 text-muted-foreground antialiased animate-scroll">
<p className="text-lg md:text-xl font-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
{/* 重复内容实现无缝滚动 */}
<p className="text-lg md:text-xl font-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
</div>
</div>
</div>
{/* 创建按钮 */}
<div className="pt-8">
<Link href="/trees/new">
<Button
size="lg"
className="gap-3 px-8 py-6 text-lg font-serif bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 shadow-lg hover:shadow-xl"
>
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
</div>
</div>
</main>
</div>
)
}
return (
<div className="flex h-screen flex-col bg-background font-sans overflow-hidden">
<SiteHeader />
<main className="flex-1 container mx-auto py-4 md:py-8 px-3 md:px-6 flex flex-col overflow-y-auto overflow-x-hidden">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-4 mb-4 md:mb-8 flex-shrink-0">
<div>
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-serif font-bold text-foreground">
{currentTree?.name || '家族族谱'}
</h1>
{currentTree?.currentUserRole && (
<span className={`text-xs px-2 py-1 rounded-full border font-sans font-normal translate-y-[2px] ${
currentTree.currentUserRole === 'OWNER'
? 'bg-purple-100 text-purple-700 border-purple-200'
: currentTree.currentUserRole === 'EDITOR'
? 'bg-blue-100 text-blue-700 border-blue-200'
: 'bg-gray-100 text-gray-700 border-gray-200'
}`}>
{currentTree.currentUserRole === 'OWNER' ? '所有者' :
currentTree.currentUserRole === 'EDITOR' ? '编辑者' : '查看者'}
</span>
)}
</div>
<p className="text-muted-foreground mt-1">
{currentTree?.description || '记录家族历史,传承家族文化'}
{stats.maxGeneration > 0 && ` · 共 ${stats.maxGeneration} 代传人`}
</p>
</div>
<div className="flex items-center gap-2">
{session && currentTree?.ownerId === session.user?.id && (
<>
<CollaboratorDialog />
<Button
variant="outline"
className="gap-2"
onClick={() => {
setEditForm({
name: currentTree?.name || '',
description: currentTree?.description || ''
})
setEditDialogOpen(true)
}}
>
<Edit className="h-4 w-4" />
</Button>
</>
)}
{permissions.canCreate(currentTree?.currentUserRole) && (
<Link href={currentTree?.id ? `/members/new?treeId=${currentTree.id}` : '/members/new'}>
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="h-4 w-4" />
</Button>
</Link>
)}
</div>
</div>
<Collapsible open={statsExpanded} onOpenChange={setStatsExpanded} className="mb-4 md:mb-8 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground -ml-2">
{statsExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<span className="text-sm font-medium"></span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4">
{/* 第一个卡片:家族成员 + 在世/已故 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
<div className="flex items-center justify-between mb-2 md:mb-3">
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="chinese-seal chinese-seal-sm">
<Users className="h-4 w-4" />
</div>
</div>
<div className="flex flex-col space-y-1">
<span className="text-2xl md:text-3xl font-serif font-bold text-foreground tracking-tight">{stats.totalMembers} </span>
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
<div className="flex items-center gap-2 md:gap-3 text-xs md:text-sm text-muted-foreground/80 font-medium">
<span className="text-green-600"> {stats.livingMembers}</span>
<span className="text-muted-foreground">·</span>
<span className="text-gray-600"> {stats.deceasedMembers}</span>
</div>
</div>
</ChineseCardContent>
</ChineseCard>
</Link>
{/* 第二个卡片:繁衍代数 + 记录年代 + 平均寿命 */}
<Link href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : '#'}>
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
<div className="flex items-center justify-between mb-2 md:mb-3">
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="chinese-seal chinese-seal-sm">
<Clock className="h-4 w-4" />
</div>
</div>
<div className="flex flex-col space-y-1">
<span className="text-2xl md:text-3xl font-serif font-bold text-foreground tracking-tight">{stats.maxGeneration} </span>
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
<div className="flex items-center gap-2 md:gap-3 text-xs md:text-sm text-muted-foreground/80 font-medium flex-wrap">
<span> {stats.yearsSpan} </span>
<span className="text-muted-foreground">·</span>
<span>寿 {stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '-'}</span>
</div>
</div>
</ChineseCardContent>
</ChineseCard>
</Link>
{/* 第三个卡片:性别比例 */}
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<ChineseCard variant="default" cornerOrnament className="chinese-stat-card transition-all hover:shadow-lg group cursor-pointer h-full">
<ChineseCardContent className="p-4 md:p-5 pt-5 md:pt-6">
<div className="flex items-center justify-between mb-2 md:mb-3">
<p className="text-xs md:text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="chinese-seal chinese-seal-sm">
<Users className="h-4 w-4" />
</div>
</div>
<div className="flex flex-col space-y-1">
<div className="flex items-center justify-center gap-2">
<span className="text-2xl md:text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? '1' : '0'}
</span>
<span className="text-xl md:text-2xl font-bold text-muted-foreground">:</span>
<span className="text-2xl md:text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
</span>
</div>
<ChineseDivider variant="simple" className="my-1.5 md:my-2" />
<div className="text-center text-xs md:text-sm text-muted-foreground/80 font-medium">
{stats.maleCount} · {stats.femaleCount}
</div>
</div>
</ChineseCardContent>
</ChineseCard>
</Link>
</div>
</CollapsibleContent>
</Collapsible>
<Tabs defaultValue="photos" className="w-full flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3 md:mb-4 flex-shrink-0 gap-2">
<TabsList className="bg-muted/50 h-8 md:h-9">
<TabsTrigger value="photos" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8"></TabsTrigger>
<TabsTrigger value="recent" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8"></TabsTrigger>
<TabsTrigger value="statistics" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8"></TabsTrigger>
<TabsTrigger value="migration" className="text-[11px] md:text-sm px-2 md:px-3 h-7 md:h-8"></TabsTrigger>
</TabsList>
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '/members'}>
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1 text-[11px] md:text-sm h-8 px-2 md:px-3">
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</div>
{/* 照片展示标签页 */}
<TabsContent value="photos" className="mt-0 flex-1 overflow-y-auto">
<ChineseCard variant="scroll" className="shadow-sm">
<ChineseCardHeader className="pt-6">
<ChineseCardTitle className="flex items-center gap-2">
<Images className="h-5 w-5 text-primary" />
</ChineseCardTitle>
<ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader>
<ChineseCardContent>
{allPhotos.length > 0 ? (
<div className="space-y-8">
{/* 按月份分组显示 */}
{groupedPhotosByMonth.map(([month, monthPhotos]) => (
<div key={month}>
<h4 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2 sticky top-0 bg-card/95 backdrop-blur py-2 z-10">
<span className="w-2 h-2 rounded-full bg-primary"></span>
{month}
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
</h4>
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
{monthPhotos.map((photo, index) => (
<div
key={`${photo.memberId}-${index}`}
className="break-inside-avoid group"
>
<PhotoCard
photo={photo}
isOwner={isOwner ?? false}
currentTree={currentTree ?? undefined}
onSelect={setSelectedPhoto}
onToggle={handleAdminPhotoToggle}
isLoading={adminToggleLoading === `${photo.memberId}|${photo.url}`}
/>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16">
<Images className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground"></p>
<p className="text-sm text-muted-foreground/70 mt-1"></p>
</div>
)}
</ChineseCardContent>
</ChineseCard>
</TabsContent>
<TabsContent value="recent" className="mt-0 flex-1 overflow-y-auto">
<div className="space-y-6">
{/* 第一行:本月祭祖和未来三月纪念日 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChineseCard variant="plaque" className="shadow-sm">
<ChineseCardHeader>
<ChineseCardTitle className="flex items-center gap-2 text-primary">
<Calendar className="h-5 w-5" />
/
</ChineseCardTitle>
<ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader>
<ChineseCardContent className="max-h-full overflow-y-auto">
{monthlyAnniversaries.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 诞辰列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-green-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
{monthlyAnniversaries.filter(a => a.type === 'birth').length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{monthlyAnniversaries.filter(a => a.type === 'birth').length > 0 ? (
monthlyAnniversaries.filter(a => a.type === 'birth').map((anniversary) => {
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear()
return (
<Link href={`/members/${anniversary.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${anniversary.member.id}-${anniversary.type}`}>
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
{anniversary.isLunar && anniversary.lunarDisplay ? (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[9px] leading-tight text-center px-1">
{anniversary.lunarDisplay.replace('月', '')}
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[10px] font-medium">
{anniversary.month}
</span>
<span className="font-bold text-sm leading-tight">{anniversary.day}</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
<MemberNameWithStatus
name={anniversary.member.fullName}
isDead={!!anniversary.member.deathDate}
/>
{anniversary.isLunar && (
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
</span>
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{anniversary.member.generation} · {yearsAgo}
</p>
</div>
</div>
</Link>
)
})
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
{/* 忌日列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-gray-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-gray-100 px-2 py-1 rounded border border-gray-200">
{monthlyAnniversaries.filter(a => a.type === 'death').length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{monthlyAnniversaries.filter(a => a.type === 'death').length > 0 ? (
monthlyAnniversaries.filter(a => a.type === 'death').map((anniversary) => {
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear()
return (
<Link href={`/members/${anniversary.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${anniversary.member.id}-${anniversary.type}`}>
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
{anniversary.isLunar && anniversary.lunarDisplay ? (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[9px] leading-tight text-center px-1">
{anniversary.lunarDisplay.replace('月', '')}
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[10px] font-medium">
{anniversary.month}
</span>
<span className="font-bold text-sm leading-tight">{anniversary.day}</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
<MemberNameWithStatus
name={anniversary.member.fullName}
isDead={!!anniversary.member.deathDate}
/>
{anniversary.isLunar && (
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
</span>
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{anniversary.member.generation} · {yearsAgo}
</p>
</div>
</div>
</Link>
)
})
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</ChineseCardContent>
</ChineseCard>
<ChineseCard variant="scroll" className="shadow-sm">
<ChineseCardHeader className="pt-6">
<ChineseCardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 text-primary" />
</ChineseCardTitle>
<ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader>
<ChineseCardContent>
{(() => {
const now = new Date()
const birthEvents = upcomingEvents.filter(e => e.type === 'birth')
const deathEvents = upcomingEvents.filter(e => e.type === 'death')
return upcomingEvents.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 诞辰列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-green-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-green-50 px-2 py-1 rounded border border-green-200">
{birthEvents.length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{birthEvents.length > 0 ? (
birthEvents.map((event, index) => {
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return (
<Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
{event.isLunar && event.lunarDisplay ? (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[9px] leading-tight text-center px-1">
{event.lunarDisplay}
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[10px] font-medium">
{event.month}
</span>
<span className="font-bold text-sm leading-tight">{event.day}</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
/>
{event.isLunar && (
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
</span>
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{event.member.generation} · {yearsAgo} · <span className="text-primary font-medium">{daysUntil}</span>
</p>
</div>
</div>
</Link>
)
})
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
{/* 忌日列 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<span className="w-1 h-4 bg-gray-500 rounded"></span>
<span className="ml-auto text-xs font-normal text-muted-foreground bg-gray-100 px-2 py-1 rounded border border-gray-200">
{deathEvents.length}
</span>
</h3>
<div className="space-y-2 max-h-[calc(5*70px)] overflow-y-auto pr-2">
{deathEvents.length > 0 ? (
deathEvents.map((event, index) => {
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
const daysUntil = Math.ceil((event.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return (
<Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
{event.isLunar && event.lunarDisplay ? (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 text-red-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[9px] leading-tight text-center px-1">
{event.lunarDisplay}
</span>
</div>
) : (
<div className="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border border-blue-200/50 text-blue-800 font-serif shadow-sm flex-shrink-0">
<span className="text-[10px] font-medium">
{event.month}
</span>
<span className="font-bold text-sm leading-tight">{event.day}</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm flex items-center gap-1 flex-wrap">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
/>
{event.isLunar && (
<span className="text-[9px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200 flex-shrink-0">
</span>
)}
</p>
<p className="text-xs text-muted-foreground truncate">
{event.member.generation} · {yearsAgo} · <span className="text-primary font-medium">{daysUntil}</span>
</p>
</div>
</div>
</Link>
)
})
) : (
<div className="text-center py-6">
<p className="text-xs text-muted-foreground"></p>
</div>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)
})()}
</ChineseCardContent>
</ChineseCard>
</div>
{/* 第二行:近期修谱动态(折叠状态) */}
<Collapsible open={recentActivityExpanded} onOpenChange={setRecentActivityExpanded} className="w-full">
<ChineseCard variant="default" cornerOrnament className="shadow-sm">
<ChineseCardHeader>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<ChineseCardTitle></ChineseCardTitle>
</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
{recentActivityExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
</div>
<ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader>
<CollapsibleContent>
<ChineseCardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-4">
{recentActivities.length > 0 ? (
recentActivities.map((activity, i) => (
<div key={activity.id} className="flex gap-3 group hover:bg-muted/30 p-2 rounded-lg transition-colors">
<div className="relative flex flex-col items-center">
<div className="h-full w-px bg-border group-last:hidden"></div>
<div className={`absolute top-2 h-2 w-2 rounded-full ring-4 ring-background ${
activity.action === 'CREATE' ? 'bg-green-500' :
activity.action === 'UPDATE' ? 'bg-blue-500' :
activity.action === 'DELETE' ? 'bg-red-500' :
'bg-purple-500'
}`}></div>
</div>
<div className="pb-2 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${
activity.action === 'CREATE' ? 'bg-green-100 text-green-700' :
activity.action === 'UPDATE' ? 'bg-blue-100 text-blue-700' :
activity.action === 'DELETE' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{activity.action === 'CREATE' ? '创建' :
activity.action === 'UPDATE' ? '更新' :
activity.action === 'DELETE' ? '删除' : '导入'}
</span>
{activity.entityId && activity.entityType === 'MEMBER' ? (
(() => {
const member = treeData.members[activity.entityId!]
if (member) {
return (
<MemberNameWithStatus
name={activity.entityName || member.fullName || '未知'}
isDead={!!member.deathDate}
memberId={activity.entityId}
treeId={currentTree?.id}
className="font-medium"
/>
)
}
// 成员不存在(可能已删除),不显示状态点,但保留链接(如果不是删除操作)
return activity.action === 'DELETE' ? (
<span className="font-medium">{activity.entityName || '未知'}</span>
) : (
<Link
href={`/members/${activity.entityId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="font-medium hover:text-primary hover:underline transition-colors"
>
{activity.entityName || '未知'}
</Link>
)
})()
) : (
<span className="font-medium">{activity.entityName || '未知'}</span>
)}
<span className="text-xs text-muted-foreground">
{format(new Date(activity.timestamp), 'MM-dd HH:mm')}
</span>
</div>
{activity.action === 'UPDATE' && activity.changes ? (
<div className="text-xs text-muted-foreground space-y-0.5">
{(() => {
const changes = activity.changes as Record<string, { old: any, new: any }>
const fieldLabels: Record<string, string> = {
fullName: '姓名', surname: '姓氏', givenName: '名字', gender: '性别',
birthDate: '出生日期', deathDate: '去世日期', birthPlace: '出生地',
ancestralHome: '祖籍', generation: '世代', generationName: '字辈',
courtesyName: '字', artName: '号', posthumousName: '谥号', rank: '排行',
bio: '简介', phone: '手机', telephone: '电话', email: '邮箱',
address: '地址', photoIds: '照片', spouseIds: '配偶', childrenIds: '子女',
motherId: '母亲', fatherId: '父亲', isFounder: '始祖',
isLunarDate: '农历日期', burialPlace: '安葬地', tags: '标签',
}
const entries = Object.entries(changes)
.filter(([key]) => fieldLabels[key])
.slice(0, 2) // 只显示前2个变更
return entries.length > 0 ? (
<>
{entries.map(([key]) => (
<div key={key}> {fieldLabels[key]}</div>
))}
{Object.keys(changes).length > 2 && (
<div className="italic"> {Object.keys(changes).length} </div>
)}
</>
) : <div></div>
})()}
</div>
) : (
<p className="text-xs text-muted-foreground">
{activity.action === 'CREATE' && '创建了成员'}
{activity.action === 'UPDATE' && '更新了成员'}
{activity.action === 'DELETE' && '删除了成员'}
{activity.action === 'IMPORT' && '导入了数据'}
</p>
)}
</div>
</div>
))
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ScrollArea>
</ChineseCardContent>
</CollapsibleContent>
</ChineseCard>
</Collapsible>
</div>
</TabsContent>
{/* 统计图表标签页 */}
<TabsContent value="statistics" className="mt-0 flex-1 overflow-y-auto">
<StatisticsCharts members={Object.values(treeData.members)} />
</TabsContent>
{/* 迁徙记录标签页 */}
<TabsContent value="migration" className="mt-0 flex-1 overflow-y-auto">
<ChineseCard variant="bamboo" className="shadow-sm">
<ChineseCardHeader>
<ChineseCardTitle></ChineseCardTitle>
<ChineseCardDescription></ChineseCardDescription>
</ChineseCardHeader>
<ChineseCardContent>
<div className="space-y-6">
{locationGroups.length > 0 ? (
locationGroups.map(([location, locationMembers]) => (
<div key={location} className="border-l-4 border-primary pl-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-serif font-bold">{location}</h3>
<span className="text-sm text-muted-foreground">
{locationMembers.length}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{locationMembers.map((member: any) => (
<Link href={`/members/${member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={member.id}>
<div className="px-3 py-2 bg-muted rounded hover:bg-muted/80 transition-colors">
<p className="text-sm font-medium truncate">{member.fullName}</p>
<p className="text-xs text-muted-foreground"> {member.generation} </p>
</div>
</Link>
))}
</div>
</div>
))
) : (
<div className="text-center py-16">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ChineseCardContent>
</ChineseCard>
</TabsContent>
</Tabs>
{/* 媒体放大预览 Dialog */}
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
<DialogContent
className="max-w-4xl p-0 bg-black/95 border-none"
overlayClassName="backdrop-blur-md bg-black/60"
aria-describedby={undefined}
>
<DialogTitle className="sr-only">{selectedPhoto && isVideoFile(selectedPhoto) ? '视频播放' : '照片预览'}</DialogTitle>
{selectedPhoto && (
isVideoFile(selectedPhoto) ? (
<video
src={selectedPhoto}
controls
autoPlay
className="w-full h-auto max-h-[90vh] object-contain"
/>
) : (
<img
src={selectedPhoto}
alt="照片预览"
className="w-full h-auto max-h-[90vh] object-contain"
/>
)
)}
</DialogContent>
</Dialog>
{/* 编辑家族信息对话框 */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tree-name"></Label>
<Input
id="tree-name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="请输入家族名称"
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tree-description"></Label>
<Textarea
id="tree-description"
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="请输入家族说明"
rows={4}
maxLength={200}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditDialogOpen(false)}
disabled={isSaving}
>
</Button>
<Button
onClick={handleSaveEdit}
disabled={isSaving || !editForm.name.trim()}
>
{isSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</div>
)
}
function StatCard({
title,
value,
subtitle,
icon,
href,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode; href?: string }) {
const cardContent = (
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
<div className="text-primary/70 bg-primary/10 p-2 rounded-lg group-hover:bg-primary/20 transition-colors">
{icon}
</div>
</div>
<div className="flex flex-col space-y-0.5">
<span className="text-2xl font-serif font-bold text-foreground tracking-tight">{value}</span>
<p className="text-xs text-muted-foreground/80 font-medium">{subtitle}</p>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
)
if (href) {
return <Link href={href}>{cardContent}</Link>
}
return cardContent
}