0.0.0.3
This commit is contained in:
+264
-78
@@ -1,107 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useMemo } 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 { Search, Plus, Filter, X } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { AvatarDisplay } from "@/components/ui/avatar-display"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
export default function MembersPage() {
|
||||
const { treeData, searchResults, searchMembers } = useFamily()
|
||||
const { treeData, searchMembers } = useFamily()
|
||||
const [query, setQuery] = useState("")
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
const displayMembers = query
|
||||
? searchResults
|
||||
: Object.values(treeData.members).sort(
|
||||
(a, b) => a.generation - b.generation || (a.birthDate || "").localeCompare(b.birthDate || ""),
|
||||
)
|
||||
// Filter States
|
||||
const [selectedSurname, setSelectedSurname] = useState<string>("all")
|
||||
const [selectedHome, setSelectedHome] = useState<string>("all")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [generationRange, setGenerationRange] = useState<[number, number]>([1, 100])
|
||||
|
||||
// Derived Data
|
||||
const allMembers = useMemo(() => Object.values(treeData.members), [treeData])
|
||||
|
||||
const { uniqueSurnames, uniqueHomes, minGen, maxGen } = useMemo(() => {
|
||||
const surnames = new Set<string>()
|
||||
const homes = new Set<string>()
|
||||
let min = 1000
|
||||
let max = 0
|
||||
|
||||
allMembers.forEach((m) => {
|
||||
if (m.surname) surnames.add(m.surname)
|
||||
if (m.ancestralHome) homes.add(m.ancestralHome)
|
||||
if (m.generation) {
|
||||
if (m.generation < min) min = m.generation
|
||||
if (m.generation > max) max = m.generation
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
uniqueSurnames: Array.from(surnames).sort(),
|
||||
uniqueHomes: Array.from(homes).sort(),
|
||||
minGen: min === 1000 ? 1 : min,
|
||||
maxGen: max === 0 ? 100 : max,
|
||||
}
|
||||
}, [allMembers])
|
||||
|
||||
// Initialize slider when data loads
|
||||
useMemo(() => {
|
||||
if (minGen !== 1000 && maxGen !== 0) {
|
||||
// Only update if range is default
|
||||
// This is a bit tricky, for now let's just rely on user adjustment or default wide range
|
||||
// Ideally we sync this once.
|
||||
}
|
||||
}, [minGen, maxGen])
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
let result = allMembers
|
||||
|
||||
// 1. Search Query
|
||||
if (query) {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m.fullName.toLowerCase().includes(lowerQuery) ||
|
||||
m.courtesyName?.toLowerCase().includes(lowerQuery) ||
|
||||
m.artName?.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Surname Filter
|
||||
if (selectedSurname !== "all") {
|
||||
result = result.filter((m) => m.surname === selectedSurname)
|
||||
}
|
||||
|
||||
// 3. Home Filter
|
||||
if (selectedHome !== "all") {
|
||||
result = result.filter((m) => m.ancestralHome === selectedHome)
|
||||
}
|
||||
|
||||
// 4. Status Filter
|
||||
if (statusFilter !== "all") {
|
||||
if (statusFilter === "living") {
|
||||
result = result.filter((m) => !m.deathDate)
|
||||
} else if (statusFilter === "deceased") {
|
||||
result = result.filter((m) => !!m.deathDate)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Generation Filter
|
||||
result = result.filter((m) => m.generation >= generationRange[0] && m.generation <= generationRange[1])
|
||||
|
||||
// Sort
|
||||
return result.sort((a, b) => a.generation - b.generation || (a.birthDate || "").localeCompare(b.birthDate || ""))
|
||||
}, [allMembers, query, selectedSurname, selectedHome, statusFilter, generationRange])
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedSurname("all")
|
||||
setSelectedHome("all")
|
||||
setStatusFilter("all")
|
||||
setGenerationRange([1, 100]) // Reset to wide range
|
||||
setQuery("")
|
||||
}
|
||||
|
||||
const activeFilterCount =
|
||||
(selectedSurname !== "all" ? 1 : 0) +
|
||||
(selectedHome !== "all" ? 1 : 0) +
|
||||
(statusFilter !== "all" ? 1 : 0) +
|
||||
(generationRange[0] > 1 || generationRange[1] < 100 ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col font-sans">
|
||||
<SiteHeader />
|
||||
<main className="container mx-auto 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" />
|
||||
新增成员
|
||||
<div className="flex flex-col gap-6 mb-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center 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)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant={showFilters || activeFilterCount > 0 ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="relative"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-primary border-2 border-background" />
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/members/new">
|
||||
<Button className="gap-2 bg-primary text-primary-foreground">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增成员
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
{showFilters && (
|
||||
<div className="bg-muted/30 p-4 rounded-lg border border-border space-y-4 animate-in slide-in-from-top-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-sm text-muted-foreground">高级筛选</h3>
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-8 text-xs">
|
||||
<X className="mr-1 h-3 w-3" /> 重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">姓氏 (Surname)</Label>
|
||||
<Select value={selectedSurname} onValueChange={setSelectedSurname}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部 (All)</SelectItem>
|
||||
{uniqueSurnames.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">籍贯 (Ancestral Home)</Label>
|
||||
<Select value={selectedHome} onValueChange={setSelectedHome}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部 (All)</SelectItem>
|
||||
{uniqueHomes.map((h) => (
|
||||
<SelectItem key={h} value={h}>
|
||||
{h}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">状态 (Status)</Label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部 (All)</SelectItem>
|
||||
<SelectItem value="living">在世 (Living)</SelectItem>
|
||||
<SelectItem value="deceased">已故 (Deceased)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label className="text-xs">世系范围 (Generation)</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{generationRange[0]} - {generationRange[1]} 世
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
defaultValue={[1, 100]}
|
||||
value={generationRange}
|
||||
min={1}
|
||||
max={100} // Can be dynamic based on maxGen
|
||||
step={1}
|
||||
onValueChange={(val) => setGenerationRange(val as [number, number])}
|
||||
className="py-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<AvatarDisplay
|
||||
imageId={member.avatarImageId}
|
||||
fallbackUrl={member.avatarUrl}
|
||||
fallbackText={member.surname}
|
||||
className="h-16 w-16 border-2 border-border"
|
||||
/>
|
||||
<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}
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
没有找到匹配的成员。
|
||||
</div>
|
||||
) : (
|
||||
filteredMembers.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">
|
||||
<AvatarDisplay
|
||||
imageId={member.avatarImageId}
|
||||
fallbackUrl={member.avatarUrl}
|
||||
fallbackText={member.surname}
|
||||
className="h-16 w-16 border-2 border-border"
|
||||
/>
|
||||
<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.deathDate && (
|
||||
<span className="text-[10px] bg-neutral-100 text-neutral-600 px-1.5 py-0.5 rounded border border-neutral-200">
|
||||
已故
|
||||
</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>
|
||||
{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>
|
||||
<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.deathDate && (
|
||||
<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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+13
-8
@@ -1,4 +1,5 @@
|
||||
import type React from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
@@ -18,14 +19,18 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
<Link href="/tree">
|
||||
<Button variant="outline" className="gap-2 bg-transparent">
|
||||
<Network className="h-4 w-4" />
|
||||
查看世系图
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user