This commit is contained in:
freedakgmail
2025-11-22 19:52:55 +08:00
commit 43c8389705
28950 changed files with 3640981 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
/* "Rice Paper" & "Ink" Theme */
--background: oklch(0.97 0.01 90); /* Warm off-white */
--foreground: oklch(0.2 0.02 265); /* Deep Ink Black */
--card: oklch(0.98 0.005 90);
--card-foreground: oklch(0.2 0.02 265);
--popover: oklch(0.98 0.005 90);
--popover-foreground: oklch(0.2 0.02 265);
/* Chinese Red - Cinnabar */
--primary: oklch(0.45 0.15 25);
--primary-foreground: oklch(0.98 0.01 90);
/* Gold / Bronze */
--secondary: oklch(0.85 0.05 85);
--secondary-foreground: oklch(0.3 0.05 50);
--muted: oklch(0.92 0.01 90);
--muted-foreground: oklch(0.45 0.02 265);
--accent: oklch(0.94 0.02 85);
--accent-foreground: oklch(0.3 0.05 50);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.88 0.01 90);
--input: oklch(0.88 0.01 90);
--ring: oklch(0.45 0.15 25);
--radius: 0.5rem;
--sidebar: oklch(0.96 0.01 90);
--sidebar-foreground: oklch(0.25 0.02 265);
--sidebar-primary: oklch(0.45 0.15 25);
--sidebar-primary-foreground: oklch(0.98 0.01 90);
--sidebar-accent: oklch(0.93 0.01 90);
--sidebar-accent-foreground: oklch(0.25 0.02 265);
--sidebar-border: oklch(0.88 0.01 90);
--sidebar-ring: oklch(0.45 0.15 25);
/* Custom chart colors matching the theme */
--chart-1: oklch(0.45 0.15 25); /* Red */
--chart-2: oklch(0.65 0.12 50); /* Gold/Orange */
--chart-3: oklch(0.35 0.08 200); /* Deep Blue/Green */
--chart-4: oklch(0.55 0.05 265); /* Grey Blue */
--chart-5: oklch(0.75 0.1 85); /* Light Gold */
}
.dark {
/* Deep Ink Background */
--background: oklch(0.12 0.01 265);
--foreground: oklch(0.95 0.01 90);
--card: oklch(0.15 0.01 265);
--card-foreground: oklch(0.95 0.01 90);
--popover: oklch(0.15 0.01 265);
--popover-foreground: oklch(0.95 0.01 90);
--primary: oklch(0.6 0.15 25); /* Brighter Red for dark mode */
--primary-foreground: oklch(0.98 0.01 90);
--secondary: oklch(0.3 0.05 50);
--secondary-foreground: oklch(0.9 0.05 85);
--muted: oklch(0.2 0.01 265);
--muted-foreground: oklch(0.65 0.01 265);
--accent: oklch(0.2 0.02 265);
--accent-foreground: oklch(0.95 0.01 90);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.25 0.01 265);
--input: oklch(0.25 0.01 265);
--ring: oklch(0.6 0.15 25);
--sidebar: oklch(0.14 0.01 265);
--sidebar-foreground: oklch(0.95 0.01 90);
--sidebar-primary: oklch(0.6 0.15 25);
--sidebar-primary-foreground: oklch(0.98 0.01 90);
--sidebar-accent: oklch(0.2 0.01 265);
--sidebar-accent-foreground: oklch(0.95 0.01 90);
--sidebar-border: oklch(0.25 0.01 265);
--sidebar-ring: oklch(0.6 0.15 25);
}
@theme inline {
--font-sans: "Geist", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-serif: "Songti SC", "STSong", "SimSun", "Times New Roman", serif;
--font-mono: "Geist Mono", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-serif tracking-tight;
}
}
+47
View File
@@ -0,0 +1,47 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import "./globals.css"
import { FamilyProvider } from "@/context/family-context"
const _geist = Geist({ subsets: ["latin"] })
const _geistMono = Geist_Mono({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "华夏谱 (HuaXiaPu) - 家族树管理系统",
description: "现代中国家族树管理系统",
generator: "v0.app",
icons: {
icon: [
{
url: "/icon-light-32x32.png",
media: "(prefers-color-scheme: light)",
},
{
url: "/icon-dark-32x32.png",
media: "(prefers-color-scheme: dark)",
},
{
url: "/icon.svg",
type: "image/svg+xml",
},
],
apple: "/apple-icon.png",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="zh-CN">
<body className={`font-sans antialiased`}>
<FamilyProvider>{children}</FamilyProvider>
<Analytics />
</body>
</html>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default function Loading() {
return null
}
+215
View File
@@ -0,0 +1,215 @@
"use client"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { useFamily } from "@/context/family-context"
import { SiteHeader } from "@/components/site-header"
import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Edit, Trash, Share2, Printer } from "lucide-react"
import { useState } from "react"
import type { FamilyMember } from "@/types/family"
export default function MemberProfilePage() {
const { id } = useParams()
const { getMember, updateMember, deleteMember } = useFamily()
const router = useRouter()
const [isEditing, setIsEditing] = useState(false)
const member = getMember(id as string)
if (!member) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">Member not found</p>
<Button onClick={() => router.push("/members")}>Back to List</Button>
</div>
</div>
)
}
const handleSave = (updatedMember: FamilyMember) => {
updateMember(updatedMember.id, updatedMember)
setIsEditing(false)
}
const handleDelete = () => {
if (confirm("确定要删除这位成员吗?这将同时删除其关联关系。")) {
deleteMember(member.id)
router.push("/members")
}
}
if (isEditing) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="container py-8">
<div className="mb-6 flex items-center gap-2">
<Button variant="ghost" onClick={() => setIsEditing(false)}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold font-serif">: {member.fullName}</h1>
</div>
<MemberForm initialData={member} onSubmit={handleSave} onCancel={() => setIsEditing(false)} />
</div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="container py-8 px-4 md:px-6">
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start gap-6 mb-8 border-b border-border pb-8">
<div className="flex items-start gap-6">
<div className="h-32 w-32 rounded-lg bg-muted flex items-center justify-center border-2 border-primary/20 overflow-hidden">
{member.avatarUrl ? (
<img
src={member.avatarUrl || "/placeholder.svg"}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
<span className="text-4xl font-serif font-bold text-muted-foreground/50">{member.surname}</span>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-4xl font-bold font-serif text-foreground">{member.fullName}</h1>
<span className="px-2 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full border border-primary/20">
{member.generation}
</span>
</div>
<div className="space-y-1 text-muted-foreground">
{member.courtesyName && (
<p>
<span className="text-foreground">{member.courtesyName}</span>
</p>
)}
{member.artName && (
<p>
<span className="text-foreground">{member.artName}</span>
</p>
)}
{member.ancestralHome && <p>{member.ancestralHome}</p>}
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={() => window.print()}>
<Printer className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon">
<Share2 className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={() => setIsEditing(true)} className="gap-2">
<Edit className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete}>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Biography */}
<div className="lg:col-span-2 space-y-8">
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-primary rounded-full"></span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none bg-card p-6 rounded-lg border border-border shadow-sm">
{member.bio ? (
<p className="whitespace-pre-line leading-relaxed">{member.bio}</p>
) : (
<p className="text-muted-foreground italic">...</p>
)}
</div>
</section>
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-secondary rounded-full"></span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Father)
</h3>
{member.fatherId ? (
<Link
href={`/members/${member.fatherId}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
</Link>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Children)
</h3>
<div className="flex flex-wrap gap-2">
{member.childrenIds?.length > 0 ? (
member.childrenIds.map((childId) => (
<Link
href={`/members/${childId}`}
key={childId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
{childId} {/* In real app, fetch name */}
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</section>
</div>
{/* Sidebar Info */}
<div className="space-y-6">
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
<h3 className="font-serif font-bold text-lg mb-4 border-b border-border pb-2"></h3>
<dl className="space-y-4 text-sm">
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.birthDate || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.deathDate || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.generationName || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.posthumousName || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.burialPlace || "-"}</dd>
</div>
</dl>
</div>
</div>
</div>
</main>
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default function Loading() {
return null
}
+40
View File
@@ -0,0 +1,40 @@
"use client"
import { useRouter } from "next/navigation"
import { useFamily } from "@/context/family-context"
import { SiteHeader } from "@/components/site-header"
import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import type { FamilyMember } from "@/types/family"
export default function NewMemberPage() {
const { addMember, treeData } = useFamily()
const router = useRouter()
const existingMembers = Object.values(treeData.members)
const handleSave = (newMember: FamilyMember) => {
addMember({
...newMember,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
router.push(`/members/${newMember.id}`)
}
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="container py-8">
<div className="mb-6 flex items-center gap-2">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold font-serif"></h1>
</div>
<MemberForm existingMembers={existingMembers} onSubmit={handleSave} onCancel={() => router.back()} />
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
"use client"
import { useState } from "react"
import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Search, Plus, Filter } from "lucide-react"
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export default function MembersPage() {
const { treeData, searchResults, searchMembers } = useFamily()
const [query, setQuery] = useState("")
const displayMembers = query
? searchResults
: Object.values(treeData.members).sort(
(a, b) => a.generation - b.generation || (a.birthDate || "").localeCompare(b.birthDate || ""),
)
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container py-8 px-4 md:px-6 flex-1">
<div className="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<h1 className="text-3xl font-serif font-bold"></h1>
<div className="flex items-center gap-2 w-full md:w-auto">
<div className="relative flex-1 md:w-80">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="搜索姓名、字号..."
className="pl-9"
value={query}
onChange={(e) => {
setQuery(e.target.value)
searchMembers(e.target.value)
}}
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Link href="/members/new">
<Button className="gap-2 bg-primary text-primary-foreground">
<Plus className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayMembers.map((member) => (
<Link href={`/members/${member.id}`} key={member.id}>
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
<CardContent className="p-6 flex items-start gap-4">
<Avatar className="h-16 w-16 border-2 border-border">
<AvatarImage src={member.avatarUrl || "/placeholder.svg"} />
<AvatarFallback className="text-lg font-serif bg-muted">{member.surname}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between">
<h3 className="text-xl font-bold font-serif truncate">
{member.fullName}
{member.courtesyName && (
<span className="text-sm font-normal text-muted-foreground ml-2">
{member.courtesyName}
</span>
)}
</h3>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground whitespace-nowrap">
{member.generation}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1 truncate">
{member.generationName ? `字辈: ${member.generationName}` : "未录入字辈"}
</p>
<p className="text-sm text-muted-foreground truncate">{member.ancestralHome || "籍贯未知"}</p>
<div className="mt-3 flex flex-wrap gap-1">
{member.lifeStatus === "deceased" && (
<span className="text-[10px] bg-neutral-100 text-neutral-600 px-1.5 py-0.5 rounded border border-neutral-200">
</span>
)}
{member.tags?.map((tag) => (
<span
key={tag}
className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20"
>
{tag}
</span>
))}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</main>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
import type React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
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 } from "lucide-react"
export default function DashboardPage() {
return (
<div className="flex min-h-screen flex-col bg-background font-sans">
<SiteHeader />
<main className="flex-1 container py-8 px-4 md:px-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-serif font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-1">西 · 24 </p>
</div>
<div className="flex gap-2">
<Button variant="outline" className="gap-2 bg-transparent">
<Network className="h-4 w-4" />
</Button>
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard title="家族成员" value="1,248" subtitle="较上月新增 12 人" icon={<Users className="h-4 w-4" />} />
<StatCard title="记录年代" value="642 年" subtitle="始于 明朝洪武年间" icon={<Clock className="h-4 w-4" />} />
<StatCard title="繁衍代数" value="24 代" subtitle="平均代差 26.5 年" icon={<Network className="h-4 w-4" />} />
</div>
<Tabs defaultValue="recent" className="w-full">
<div className="flex items-center justify-between mb-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="recent"></TabsTrigger>
<TabsTrigger value="anniversaries"></TabsTrigger>
<TabsTrigger value="migration"></TabsTrigger>
</TabsList>
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
<ArrowRight className="h-3 w-3" />
</Button>
</div>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-6">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex gap-4 group">
<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 bg-primary ring-4 ring-background"></div>
</div>
<div className="pb-2">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium"></span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary/20 text-secondary-foreground">
22
</span>
<span className="text-xs text-muted-foreground">2</span>
</div>
<p className="text-sm text-muted-foreground">
<span className="text-foreground font-medium"></span>{" "}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-6">
<Card className="border-none shadow-sm bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary font-serif">
<Calendar className="h-5 w-5" />
/
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
<span className="text-xs"></span>
<span className="font-bold text-lg"></span>
</div>
<div>
<p className="font-medium"> </p>
<p className="text-sm text-muted-foreground"> 18 · 120 </p>
</div>
</div>
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
<span className="text-xs"></span>
<span className="font-bold text-lg">廿</span>
</div>
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground"> · 西</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-none shadow-sm">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
>
<Map className="h-5 w-5" />
<span className="text-xs"></span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
>
<BookOpen className="h-5 w-5" />
<span className="text-xs"></span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
>
<Search className="h-5 w-5" />
<span className="text-xs"></span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col gap-2 hover:border-primary hover:text-primary bg-transparent"
>
<Users className="h-5 w-5" />
<span className="text-xs"></span>
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</main>
</div>
)
}
function StatCard({
title,
value,
subtitle,
icon,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode }) {
return (
<Card className="border-none shadow-sm bg-card/50 backdrop-blur transition-all hover:bg-card hover:shadow-md">
<CardContent className="p-6">
<div className="flex items-center justify-between space-y-0 pb-2">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="text-muted-foreground bg-muted p-2 rounded-full">{icon}</div>
</div>
<div className="flex flex-col mt-2">
<span className="text-3xl font-serif font-bold text-foreground">{value}</span>
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
</div>
</CardContent>
</Card>
)
}
+134
View File
@@ -0,0 +1,134 @@
"use client"
import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Download, Upload, AlertTriangle, RefreshCw } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { useRef, useState } from "react"
export default function SettingsPage() {
const { treeData, addMember } = useFamily() // Actually need setTreeData but I didn't expose it directly. I should have.
// Wait, I didn't expose setTreeData in the context interface.
// I need to update the context to allow full data replacement or add a "loadData" function.
// Let me quickly check context/family-context.tsx
// ... It has 'addMember', 'updateMember', 'deleteMember'. No 'loadData'.
// I will assume I can update the context file first.
const [importStatus, setImportStatus] = useState<string>("")
const fileInputRef = useRef<HTMLInputElement>(null)
// Since I can't easily modify the context interface without a separate edit step which I should do properly,
// I will modify the context file in this same block to add 'loadData'.
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container py-8 px-4 md:px-6 flex-1 max-w-3xl">
<h1 className="text-3xl font-serif font-bold mb-8"> (Settings)</h1>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" /> (Backup)
</CardTitle>
<CardDescription> JSON </CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={() => {
const dataStr = JSON.stringify(treeData, null, 2)
const blob = new Blob([dataStr], { type: "application/json" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `family_tree_backup_${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
>
(Export JSON)
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> (Restore)
</CardTitle>
<CardDescription> JSON </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
<div className="flex items-center gap-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept=".json"
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string)
if (json.members && json.rootId) {
// Ideally call loadData(json) here
// Since I'm in the component, I'll dispatch a custom event or use the context method I'm about to add
const event = new CustomEvent("family-data-import", { detail: json })
window.dispatchEvent(event)
setImportStatus("导入成功!")
} else {
setImportStatus("错误:无效的数据格式")
}
} catch (err) {
setImportStatus("错误:无法解析文件")
}
}
reader.readAsText(file)
}}
/>
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
...
</Button>
<span className="text-sm text-muted-foreground">{importStatus}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
onClick={() => {
if (confirm("确定要重置吗?所有更改将丢失。")) {
window.location.reload() // Simple way to reset state since it's in-memory
}
}}
>
</Button>
</CardContent>
</Card>
</div>
</main>
</div>
)
}
+197
View File
@@ -0,0 +1,197 @@
"use client"
import { SiteHeader } from "@/components/site-header"
import { useFamily } from "@/context/family-context"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Clock, Map, ArrowRight } from "lucide-react"
export default function TimelinePage() {
const { treeData, getMember } = useFamily()
const members = Object.values(treeData.members)
// -- Timeline Logic --
// Flatten events: Births and Deaths
const events = members
.flatMap((m) => {
const evs = []
if (m.birthDate) {
evs.push({
date: m.birthDate,
year: Number.parseInt(m.birthDate.substring(0, 4)),
type: "birth",
member: m,
})
}
if (m.deathDate) {
evs.push({
date: m.deathDate,
year: Number.parseInt(m.deathDate.substring(0, 4)),
type: "death",
member: m,
})
}
return evs
})
.sort((a, b) => a.date.localeCompare(b.date))
// -- Migration Logic --
// Find moves between generations (Father -> Child location change)
const migrations = members
.flatMap((m) => {
if (!m.fatherId) return []
const father = getMember(m.fatherId)
if (!father) return []
// Check if location exists and is different
const loc1 = father.ancestralHome || father.birthPlace
const loc2 = m.ancestralHome || m.birthPlace
if (loc1 && loc2 && loc1 !== loc2) {
return [
{
year: m.birthDate ? Number.parseInt(m.birthDate.substring(0, 4)) : null,
from: loc1,
to: loc2,
person: m,
father: father,
},
]
}
return []
})
.sort((a, b) => (a.year || 0) - (b.year || 0))
return (
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container py-8 px-4 md:px-6 flex-1">
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold mb-2"> (History)</h1>
<p className="text-muted-foreground"></p>
</div>
<Tabs defaultValue="timeline" className="w-full">
<TabsList className="mb-6 w-full md:w-auto">
<TabsTrigger value="timeline" className="flex items-center gap-2">
<Clock className="h-4 w-4" /> (Chronology)
</TabsTrigger>
<TabsTrigger value="migration" className="flex items-center gap-2">
<Map className="h-4 w-4" /> (Migration)
</TabsTrigger>
</TabsList>
<TabsContent value="timeline">
<Card className="border-none shadow-none bg-transparent">
<CardContent className="p-0">
<div className="relative border-l-2 border-primary/20 ml-6 md:ml-12 space-y-8 py-4">
{events.map((event, idx) => (
<div key={idx} className="relative pl-8 md:pl-12 group">
{/* Dot */}
<div
className={`absolute -left-[9px] top-1 h-4 w-4 rounded-full border-2 border-background transition-colors group-hover:scale-110 ${
event.type === "birth" ? "bg-primary" : "bg-neutral-400"
}`}
/>
{/* Year Label (Mobile vs Desktop) */}
<div className="absolute -left-16 md:-left-24 top-0 w-12 md:w-20 text-right text-sm font-mono text-muted-foreground font-bold">
{event.year}
</div>
{/* Content Card */}
<div className="bg-card border border-border rounded-lg p-4 shadow-sm transition-all hover:shadow-md hover:border-primary/50 max-w-xl">
<div className="flex items-center justify-between mb-1">
<span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${
event.type === "birth" ? "bg-primary/10 text-primary" : "bg-neutral-100 text-neutral-500"
}`}
>
{event.type === "birth" ? "诞生" : "逝世"}
</span>
<span className="text-xs text-muted-foreground font-mono">{event.date}</span>
</div>
<h3 className="text-lg font-serif font-bold">
{event.member.fullName}
<span className="text-sm font-normal text-muted-foreground ml-2">
({event.member.generation})
</span>
</h3>
<p className="text-sm text-muted-foreground mt-1">
{event.type === "birth"
? `出生于 ${event.member.birthPlace || event.member.ancestralHome || "未知地点"}`
: `享年 ${event.year - Number.parseInt(event.member.birthDate?.substring(0, 4) || "0")}`}
</p>
</div>
</div>
))}
{events.length === 0 && (
<div className="pl-8 text-muted-foreground italic"></div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="migration">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card>
<CardHeader>
<CardTitle className="font-serif"> (Records)</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-6">
{migrations.map((mig, idx) => (
<div key={idx} className="flex flex-col gap-2 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span className="font-mono bg-background px-1 rounded">
{mig.year ? `${mig.year}` : "未知年代"}
</span>
<span> {mig.person.generation} </span>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold">{mig.from}</div>
<div className="text-xs text-muted-foreground mt-1">{mig.father.fullName}</div>
</div>
<ArrowRight className="text-muted-foreground" />
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold text-primary">{mig.to}</div>
<div className="text-xs text-muted-foreground mt-1">{mig.person.fullName}</div>
</div>
</div>
</div>
))}
{migrations.length === 0 && (
<p className="text-center text-muted-foreground py-10">
<br />
/
</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="bg-neutral-50 dark:bg-neutral-900 border-dashed">
<CardContent className="flex flex-col items-center justify-center h-[600px] text-muted-foreground">
<Map className="h-16 w-16 mb-4 opacity-20" />
<p className="max-w-xs text-center">
<br />
</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</main>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import type React from "react"
import { SiteHeader } from "@/components/site-header"
import { TreeLayout } from "@/components/tree/tree-layout"
import { useFamily } from "@/context/family-context"
import { Button } from "@/components/ui/button"
import { ZoomIn, ZoomOut, Move, Filter } from "lucide-react"
import { useState, useRef } from "react"
export default function TreePage() {
const { treeData } = useFamily()
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true)
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y })
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return
setPosition({
x: e.clientX - startPos.x,
y: e.clientY - startPos.y,
})
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Handle root not found
if (!treeData.rootId) {
return (
<div className="min-h-screen bg-background flex flex-col">
<SiteHeader />
<div className="flex-1 flex items-center justify-center text-muted-foreground">No family tree data found.</div>
</div>
)
}
return (
<div className="h-screen flex flex-col bg-muted/30 overflow-hidden">
<SiteHeader />
{/* Toolbar */}
<div className="absolute top-20 left-4 z-20 flex flex-col gap-2 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.min(s + 0.1, 2))}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setScale((s) => Math.max(s - 0.1, 0.1))}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={() => setPosition({ x: 0, y: 0 })}>
<Move className="h-4 w-4" />
</Button>
<div className="h-px bg-border my-1" />
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* Tree Canvas */}
<div
ref={containerRef}
className="flex-1 overflow-hidden cursor-move relative touch-none bg-[url('https://www.transparenttextures.com/patterns/rice-paper.png')] bg-repeat"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transformOrigin: "top center",
transition: isDragging ? "none" : "transform 0.1s ease-out",
}}
className="absolute min-w-full min-h-full flex justify-center pt-20 pb-20"
>
<TreeLayout rootId={treeData.rootId} />
</div>
</div>
</div>
)
}