Skip to content

Commit edc6efc

Browse files
brian033claude
andcommitted
content: redesign member page with real professor info and filters
- Replace hallucinated bio/highlights with real professor contact details (email, phone, research topics, teaching areas) from the home page - Add YouTube, LinkedIn, Medium links; remove placeholder ORCID - Flatten card-in-card layout to single gradient card - Add year and category (博士/碩士/學士/研究助理) filter pills - Reduce spacing between PI card and member directory - Update test assertions for new headings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a1f105c commit edc6efc

4 files changed

Lines changed: 226 additions & 52 deletions

File tree

src/app/App.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("Dense content pages", () => {
9393

9494
expect(screen.getByRole("heading", { name: / Chien-Yu Chen/ })).toBeVisible();
9595
expect(screen.getByRole("heading", { name: /current researchers/i })).toBeVisible();
96-
expect(screen.getByRole("heading", { name: /alumni network/i })).toBeVisible();
96+
expect(screen.getByRole("heading", { name: /alumni/i })).toBeVisible();
9797
expect(screen.getAllByText(/ /)[0]).toBeVisible();
9898
});
9999
});

src/components/sections/FeaturedPISection.tsx

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,50 @@
11
import { featuredMember } from "../../data/mock/members";
22
import { ExternalLink } from "../ui/ExternalLink";
33
import { SectionShell } from "../ui/SectionShell";
4-
import { Tag } from "../ui/Tag";
54

65
export function FeaturedPISection() {
76
return (
8-
<SectionShell className="pt-10">
9-
<div className="grid gap-8 rounded-[2rem] border border-slate-200 bg-white/80 p-8 shadow-soft lg:grid-cols-[0.9fr_1.1fr]">
10-
<div className="rounded-[1.75rem] bg-gradient-to-br from-navy via-primary to-secondary p-8 text-white">
11-
<p className="text-xs font-bold uppercase tracking-[0.25em] text-sky-100">Featured Investigator</p>
12-
<h2 className="mt-4 text-4xl text-white">{featuredMember.name}</h2>
13-
<p className="mt-3 text-sm font-semibold uppercase tracking-[0.18em] text-sky-100">{featuredMember.role}</p>
7+
<SectionShell className="pt-10 pb-6 sm:pb-6 lg:pb-6">
8+
<div className="overflow-hidden rounded-[2rem] bg-gradient-to-br from-navy via-primary to-secondary p-8 text-white lg:p-10">
9+
<p className="text-xs font-bold uppercase tracking-[0.25em] text-sky-100">Featured Investigator</p>
10+
11+
<div className="mt-6 flex items-center gap-5">
12+
<img
13+
src="/images/prof-chen.png"
14+
alt="陳倩瑜 Prof. Chen, Chien-Yu"
15+
className="h-20 w-20 rounded-full border-2 border-white/30 object-cover"
16+
/>
17+
<div>
18+
<h2 className="text-3xl text-white">{featuredMember.name}</h2>
19+
<p className="mt-1 text-sm font-semibold uppercase tracking-[0.18em] text-sky-100">{featuredMember.role}</p>
20+
<p className="mt-1 text-sm text-sky-100">{featuredMember.bio}</p>
21+
</div>
1422
</div>
15-
<div>
16-
<Tag>Research Leadership</Tag>
17-
<p className="mt-4 text-base leading-8 text-slate-600">{featuredMember.bio}</p>
18-
<ul className="mt-6 space-y-3 text-sm text-slate-600">
19-
{featuredMember.highlights.map((highlight) => (
20-
<li key={highlight}>{highlight}</li>
21-
))}
22-
</ul>
23-
<div className="mt-6 flex flex-wrap gap-4">
23+
24+
<div className="mt-8 grid gap-8 lg:grid-cols-2">
25+
<dl className="space-y-3 text-sm text-sky-50">
26+
<div>
27+
<dt className="font-semibold text-white">E-mail</dt>
28+
<dd><a href="mailto:chienyuchen@ntu.edu.tw" className="hover:text-white/80">chienyuchen@ntu.edu.tw</a></dd>
29+
</div>
30+
<div>
31+
<dt className="font-semibold text-white">Tel</dt>
32+
<dd>02-3366-5335 (系主任辦公室)</dd>
33+
<dd>02-3366-5334</dd>
34+
</div>
35+
<div>
36+
<dt className="font-semibold text-white">研究主題</dt>
37+
<dd>生物資訊、資料探勘、機器學習</dd>
38+
</div>
39+
<div>
40+
<dt className="font-semibold text-white">授課領域</dt>
41+
<dd>人工智慧概論、資料結構與演算法、生物資訊基石、生醫資料探勘</dd>
42+
</div>
43+
</dl>
44+
45+
<div className="flex flex-wrap content-start gap-3">
2446
{featuredMember.links.map((link) => (
25-
<ExternalLink key={link.href} href={link.href}>
47+
<ExternalLink key={link.href} href={link.href} className="text-sky-100 hover:text-white">
2648
{link.label}
2749
</ExternalLink>
2850
))}
Lines changed: 180 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,205 @@
1+
import { useState, useMemo } from "react";
12
import { memberRecords } from "../../data/mock/members";
23
import type { MemberRecord } from "../../types/content";
34
import { Card } from "../ui/Card";
45
import { SectionShell } from "../ui/SectionShell";
5-
import { SectionHeading } from "../ui/SectionHeading";
6+
7+
const CURRENT_YEAR = new Date().getFullYear();
8+
9+
const DEGREE_CATEGORIES = ["教授", "博士", "碩士", "學士", "研究助理"] as const;
10+
type DegreeCategory = (typeof DEGREE_CATEGORIES)[number];
11+
12+
function parseYearRange(yearLabel: string): [number, number] | null {
13+
if (!yearLabel) return null;
14+
const ongoingMatch = yearLabel.match(/^(\d{4})\s*~/);
15+
if (ongoingMatch) return [Number(ongoingMatch[1]), CURRENT_YEAR];
16+
const rangeMatch = yearLabel.match(/^(\d{4})\s*-\s*(\d{4})/);
17+
if (rangeMatch) return [Number(rangeMatch[1]), Number(rangeMatch[2])];
18+
return null;
19+
}
20+
21+
function getDegreeCategories(role: string): DegreeCategory[] {
22+
const cats: DegreeCategory[] = [];
23+
if (role.includes("教授")) cats.push("教授");
24+
if (role.includes("博士")) cats.push("博士");
25+
if (role.includes("碩士")) cats.push("碩士");
26+
if (role.includes("學士")) cats.push("學士");
27+
if (role.includes("RA")) cats.push("研究助理");
28+
return cats;
29+
}
630

731
function MemberCard({ member }: { member: MemberRecord }) {
832
return (
933
<Card className="rounded-[1.5rem] p-5">
1034
<p className="text-xs font-bold uppercase tracking-[0.2em] text-slate-500">{member.yearLabel}</p>
1135
<h3 className="mt-3 text-2xl">{member.name}</h3>
1236
<p className="mt-2 text-sm font-semibold uppercase tracking-[0.16em] text-primary">{member.role}</p>
13-
<p className="mt-4 text-sm leading-7 text-slate-600">{member.focus}</p>
37+
{member.focus && <p className="mt-4 text-sm leading-7 text-slate-600">{member.focus}</p>}
1438
</Card>
1539
);
1640
}
1741

42+
function FilterPill({
43+
label,
44+
active,
45+
onClick,
46+
}: {
47+
label: string;
48+
active: boolean;
49+
onClick: () => void;
50+
}) {
51+
return (
52+
<button
53+
onClick={onClick}
54+
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
55+
active
56+
? "bg-navy text-white"
57+
: "border border-slate-200 bg-white text-slate-600 hover:text-navy"
58+
}`}
59+
>
60+
{label}
61+
</button>
62+
);
63+
}
64+
1865
export function MemberDirectorySection() {
19-
const currentMembers = memberRecords.filter((member) => member.status === "current");
20-
const alumniMembers = memberRecords.filter((member) => member.status === "alumni");
66+
// Skip the professor (member-1) from directory listing
67+
const directoryMembers = memberRecords.filter((m) => m.id !== "member-1");
68+
69+
// Collect all unique years that members span
70+
const allYears = useMemo(() => {
71+
const years = new Set<number>();
72+
for (const m of directoryMembers) {
73+
const range = parseYearRange(m.yearLabel);
74+
if (range) {
75+
for (let y = range[0]; y <= range[1]; y++) years.add(y);
76+
}
77+
}
78+
return Array.from(years).sort((a, b) => b - a);
79+
}, []);
80+
81+
// Collect which degree categories actually exist in data
82+
const availableCategories = useMemo(() => {
83+
const cats = new Set<DegreeCategory>();
84+
for (const m of directoryMembers) {
85+
for (const c of getDegreeCategories(m.role)) cats.add(c);
86+
}
87+
return DEGREE_CATEGORIES.filter((c) => cats.has(c));
88+
}, []);
89+
90+
const [selectedYears, setSelectedYears] = useState<Set<number>>(new Set(allYears));
91+
const [selectedCategories, setSelectedCategories] = useState<Set<DegreeCategory>>(new Set(availableCategories));
92+
93+
const allYearsSelected = selectedYears.size === allYears.length;
94+
const allCategoriesSelected = selectedCategories.size === availableCategories.length;
95+
96+
function toggleYear(year: number) {
97+
setSelectedYears((prev) => {
98+
const next = new Set(prev);
99+
if (next.has(year)) next.delete(year);
100+
else next.add(year);
101+
return next;
102+
});
103+
}
104+
105+
function toggleAllYears() {
106+
setSelectedYears(allYearsSelected ? new Set() : new Set(allYears));
107+
}
108+
109+
function toggleCategory(cat: DegreeCategory) {
110+
setSelectedCategories((prev) => {
111+
const next = new Set(prev);
112+
if (next.has(cat)) next.delete(cat);
113+
else next.add(cat);
114+
return next;
115+
});
116+
}
117+
118+
function toggleAllCategories() {
119+
setSelectedCategories(allCategoriesSelected ? new Set() : new Set(availableCategories));
120+
}
121+
122+
// Filter members: must match at least one selected year AND at least one selected category
123+
const filtered = directoryMembers.filter((m) => {
124+
const range = parseYearRange(m.yearLabel);
125+
const yearMatch =
126+
selectedYears.size === 0
127+
? false
128+
: range
129+
? Array.from(selectedYears).some((y) => y >= range[0] && y <= range[1])
130+
: false;
131+
132+
const cats = getDegreeCategories(m.role);
133+
const catMatch =
134+
selectedCategories.size === 0
135+
? false
136+
: cats.length === 0
137+
? true // show members with no category if no filtering
138+
: cats.some((c) => selectedCategories.has(c));
139+
140+
return yearMatch && catMatch;
141+
});
142+
143+
const currentMembers = filtered.filter((m) => m.status === "current");
144+
const alumniMembers = filtered.filter((m) => m.status === "alumni");
21145

22146
return (
23-
<SectionShell>
24-
<section>
25-
<SectionHeading
26-
eyebrow="Directory"
27-
title="Current Researchers"
28-
description="The member module separates active researchers from alumni so later content imports can preserve both current and historical structure."
29-
/>
30-
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
31-
{currentMembers.map((member) => (
32-
<MemberCard key={member.id} member={member} />
147+
<SectionShell className="pt-6 sm:pt-6 lg:pt-6">
148+
{/* Year filter */}
149+
<div className="mb-4">
150+
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-slate-500">Year</p>
151+
<div className="flex flex-wrap gap-2">
152+
<FilterPill label="All" active={allYearsSelected} onClick={toggleAllYears} />
153+
{allYears.map((year) => (
154+
<FilterPill key={year} label={String(year)} active={selectedYears.has(year)} onClick={() => toggleYear(year)} />
33155
))}
34156
</div>
35-
</section>
36-
37-
<section className="mt-16">
38-
<SectionHeading
39-
eyebrow="History"
40-
title="Alumni Network"
41-
description="A separate alumni band keeps the archive visible without flattening the active team presentation."
42-
/>
43-
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
44-
{alumniMembers.map((member) => (
45-
<MemberCard key={member.id} member={member} />
157+
</div>
158+
159+
{/* Category filter */}
160+
<div className="mb-8">
161+
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-slate-500">Category</p>
162+
<div className="flex flex-wrap gap-2">
163+
<FilterPill label="All" active={allCategoriesSelected} onClick={toggleAllCategories} />
164+
{availableCategories.map((cat) => (
165+
<FilterPill key={cat} label={cat} active={selectedCategories.has(cat)} onClick={() => toggleCategory(cat)} />
46166
))}
47167
</div>
48-
</section>
168+
</div>
169+
170+
{currentMembers.length > 0 && (
171+
<section>
172+
<div className="mb-5 flex items-center gap-4">
173+
<h2 className="text-3xl">Current Researchers</h2>
174+
<div className="h-px flex-1 bg-slate-200" />
175+
<span className="text-sm text-slate-500">{currentMembers.length}</span>
176+
</div>
177+
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
178+
{currentMembers.map((member) => (
179+
<MemberCard key={member.id} member={member} />
180+
))}
181+
</div>
182+
</section>
183+
)}
184+
185+
{alumniMembers.length > 0 && (
186+
<section className="mt-16">
187+
<div className="mb-5 flex items-center gap-4">
188+
<h2 className="text-3xl">Alumni</h2>
189+
<div className="h-px flex-1 bg-slate-200" />
190+
<span className="text-sm text-slate-500">{alumniMembers.length}</span>
191+
</div>
192+
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
193+
{alumniMembers.map((member) => (
194+
<MemberCard key={member.id} member={member} />
195+
))}
196+
</div>
197+
</section>
198+
)}
199+
200+
{filtered.length === 0 && (
201+
<p className="py-16 text-center text-slate-400">No members match the selected filters.</p>
202+
)}
49203
</SectionShell>
50204
);
51205
}

src/data/mock/members.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,13 @@ export const memberHero: PageHeroContent = {
1010
export const featuredMember: FeaturedMember = {
1111
name: "陳倩瑜 Chien-Yu Chen",
1212
role: "教授(系主任)/ Founder of c4Lab",
13-
bio: "Professor Chen founded c4Lab at National Taiwan University, focusing on computational genomics, AI for life science, and biologically grounded machine learning methods.",
14-
highlights: [
15-
"Computational genomics and sequence interpretation",
16-
"Deep learning for immunogenomics",
17-
"Precision medicine in Taiwanese cohorts"
18-
],
13+
bio: "The founder of c4Lab",
14+
highlights: [],
1915
links: [
20-
{ label: "ORCID Profile", href: "https://orcid.org/", external: true },
21-
{ label: "Facebook", href: "https://www.facebook.com/chienyu.chen.37", external: true },
16+
{ label: "Facebook", href: "https://www.facebook.com/chienyu.chen.37", external: true },
17+
{ label: "YouTube", href: "https://www.youtube.com/@chien-yuchen9796", external: true },
18+
{ label: "LinkedIn", href: "https://www.linkedin.com/in/chien-yu-chen-1bb69725b/", external: true },
19+
{ label: "Medium", href: "https://medium.com/@chienyuchen_75596", external: true },
2220
{ label: "Contact c4Lab", href: "mailto:chienyuchen@ntu.edu.tw", external: true }
2321
]
2422
};

0 commit comments

Comments
 (0)