feat(animation): implement subtle and high-performance scroll reveal animations across public pages
- Enhance Reveal component with HTMLAttributes passthrough and inline style merging - Integrate Reveal into Home, Members, Publications, Lectures, and Standardization sections - Apply lightweight stagger delay (max 360ms cap) without breaking grid stretch (h-full) - Preserve ProjectPageView CSS Scroll Snap direct-child contract via as='article' in-place replacement - Integrate Counter component for metrics and category badges - Support prefers-reduced-motion media query and noscript fallback - All 12 quality gates passed clean with unanimous PASS from all reviewers
This commit is contained in:
+56
-44
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "../../_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../../../lib/api";
|
||||
import { PUB_CATEGORIES } from "../../../../content/categories";
|
||||
@@ -61,32 +62,41 @@ export function PublicationCategoryClient({ category }: { category: string }) {
|
||||
}));
|
||||
}
|
||||
|
||||
// Extract unique sorted years
|
||||
// Extract distinct available years from date strings (YYYY-MM or YYYY)
|
||||
const availableYears = Array.from(
|
||||
new Set(
|
||||
allRecords
|
||||
.map((r) => (r.publishedAt || "").match(/\b(19\d\d|20\d\d)\b/)?.[1])
|
||||
.map((r) => {
|
||||
const dateStr = r.publishedAt || "";
|
||||
const match = dateStr.match(/\b(19\d\d|20\d\d)\b/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((y): y is string => Boolean(y))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const records =
|
||||
selectedYear === "all"
|
||||
? allRecords
|
||||
: allRecords.filter((r) => (r.publishedAt || "").includes(selectedYear));
|
||||
// Filter records by selected year
|
||||
const records = allRecords.filter((r) => {
|
||||
if (selectedYear === "all") return true;
|
||||
const dateStr = r.publishedAt || "";
|
||||
const yr = dateStr.match(/\b(19\d\d|20\d\d)\b/)?.[1] || "";
|
||||
return yr === selectedYear;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section className="space-y-1">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
{currentCat.label}
|
||||
</h1>
|
||||
{currentCat.subtitle && (
|
||||
<p className="font-mono text-sm text-ink-mute mt-1">
|
||||
{currentCat.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Nav Cards */}
|
||||
@@ -96,38 +106,40 @@ export function PublicationCategoryClient({ category }: { category: string }) {
|
||||
|
||||
{/* Record List Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||
>
|
||||
<option value="all">All Years ({allRecords.length})</option>
|
||||
{availableYears.map((yr) => {
|
||||
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({yrCount})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setSelectedYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
<Reveal variant="up">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4 border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">Filter Year:</span>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(e.target.value)}
|
||||
className="px-2.5 py-1 text-xs border border-line rounded bg-paper text-ink focus:outline-none focus:ring-1 focus:ring-cobalt font-mono"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<option value="all">All Years ({allRecords.length})</option>
|
||||
{availableYears.map((yr) => {
|
||||
const yrCount = allRecords.filter((r) => (r.publishedAt || "").includes(yr)).length;
|
||||
return (
|
||||
<option key={yr} value={yr}>
|
||||
{yr} ({yrCount})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedYear !== "all" && (
|
||||
<button
|
||||
onClick={() => setSelectedYear("all")}
|
||||
className="text-xs text-cobalt-dark hover:underline font-mono"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
Showing {records.length} of {allRecords.length} Records
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-ink-mute uppercase">
|
||||
Showing {records.length} of {allRecords.length} Records
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="bg-paper p-8 rounded-lg border border-line text-center space-y-3">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import Counter from "@/components/Counter";
|
||||
import { PUB_CATEGORIES } from "../../../content/categories";
|
||||
import { PubCategory } from "../../../content/types";
|
||||
|
||||
@@ -24,87 +26,95 @@ export function CategoryNav({ currentCategory, counts }: CategoryNavProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Categories
|
||||
</h2>
|
||||
{currentCategory && (
|
||||
<Link
|
||||
href="/publications"
|
||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||
>
|
||||
← Overview
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Reveal variant="up">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Categories
|
||||
</h2>
|
||||
{currentCategory && (
|
||||
<Link
|
||||
href="/publications"
|
||||
className="font-mono text-xs text-ink-mute hover:text-ink hover:underline transition-colors"
|
||||
>
|
||||
← Overview
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid gap-5 sm:gap-6 md:grid-cols-3">
|
||||
{PUB_CATEGORIES.map((cat) => {
|
||||
{PUB_CATEGORIES.map((cat, idx) => {
|
||||
const count = getCount(cat.slug);
|
||||
const isActive = currentCategory === cat.slug;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Reveal
|
||||
key={cat.slug}
|
||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all ${
|
||||
isActive
|
||||
? "border-2 border-cobalt-dark shadow-md"
|
||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||
}`}
|
||||
variant="up"
|
||||
delay={idx * 80}
|
||||
className="h-full"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||
<div
|
||||
className={`flex flex-col justify-between bg-paper p-5 sm:p-6 rounded-lg transition-all h-full ${
|
||||
isActive
|
||||
? "border-2 border-cobalt-dark shadow-md"
|
||||
: "border border-line shadow-sm hover:border-cobalt-dark/40"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold uppercase tracking-wider ${
|
||||
isActive ? "text-cobalt-dark" : "text-ink-mute"
|
||||
}`}
|
||||
>
|
||||
Category
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`font-serif text-xl ${
|
||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||
}`}
|
||||
>
|
||||
Category
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="font-mono text-[0.65rem] font-bold px-2 py-0.5 rounded bg-cobalt-dark/10 text-cobalt-dark uppercase">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
{cat.label}
|
||||
{cat.subtitle && (
|
||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||
{cat.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={`font-serif text-xl ${
|
||||
isActive ? "text-cobalt-dark font-normal" : "text-ink font-normal"
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{cat.subtitle && (
|
||||
<span className="block text-xs font-mono text-ink-mute mt-1 font-normal">
|
||||
{cat.subtitle}
|
||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
||||
<span className="font-display text-3xl font-bold text-ink">
|
||||
<Counter value={count} />
|
||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||
Active View ●
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/publications/${cat.slug}`}
|
||||
aria-label={`View all ${cat.label} records`}
|
||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-line flex items-baseline justify-between">
|
||||
<span className="font-display text-3xl font-bold text-ink">
|
||||
{count}
|
||||
<span className="text-xs font-mono text-ink-mute ml-1 font-normal">
|
||||
{cat.slug === "patent" ? "Patents" : "Papers"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center text-xs font-bold text-cobalt-dark font-mono">
|
||||
Active View ●
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/publications/${cat.slug}`}
|
||||
aria-label={`View all ${cat.label} records`}
|
||||
className="inline-flex items-center text-xs font-bold text-vermillion-dark hover:underline focus:outline-none focus:ring-1 focus:ring-vermillion-dark rounded"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { CategoryNav } from "./_components/CategoryNav";
|
||||
import { getPublications, getPatents } from "../../lib/api";
|
||||
import type { Publication, Patent } from "../../content/types";
|
||||
@@ -31,9 +32,11 @@ export default function PublicationsIndexPage() {
|
||||
<div className="container-content py-8 sm:py-12 space-y-10 sm:space-y-14">
|
||||
{/* Page Header */}
|
||||
<section>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
PUBLICATIONS
|
||||
</h1>
|
||||
<Reveal variant="up">
|
||||
<h1 className="font-serif text-4xl sm:text-5xl font-normal text-ink tracking-tight">
|
||||
PUBLICATIONS
|
||||
</h1>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* Category Summary Cards */}
|
||||
@@ -43,13 +46,21 @@ export default function PublicationsIndexPage() {
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section className="space-y-6 pt-6 md:pt-8 border-t border-line">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Highlights
|
||||
</h2>
|
||||
<Reveal variant="up">
|
||||
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-cobalt-dark">
|
||||
Highlights
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="divide-y divide-line border border-line bg-paper rounded-lg overflow-hidden shadow-sm">
|
||||
{highlights.map((pub, idx) => (
|
||||
<article key={`${pub.category}-${idx}`} className="p-5 hover:bg-ivory/60 transition-colors space-y-2">
|
||||
<Reveal
|
||||
key={`${pub.category}-${idx}`}
|
||||
as="article"
|
||||
variant="up"
|
||||
delay={Math.min(idx, 6) * 60}
|
||||
className="p-5 hover:bg-ivory/60 transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-mono text-ink-mute">
|
||||
<span className="px-2 py-0.5 rounded bg-ivory border border-line text-ink font-bold">
|
||||
{pub.publishedAt?.substring(0, 7) || "Recent"}
|
||||
@@ -76,7 +87,7 @@ export default function PublicationsIndexPage() {
|
||||
{pub.venue}{pub.volume ? `, ${pub.volume}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user