feat: implement Research Project PageView (v1.8) with static SSG pre-render and E2E gates

This commit is contained in:
2026-07-30 21:54:22 +09:00
parent 66fae1ebec
commit 1c772ae091
10 changed files with 623 additions and 21 deletions
@@ -0,0 +1,132 @@
import React from "react";
import { researchProjects } from "../../content/projects";
import ProjectPageViewControls from "./ProjectPageViewControls";
export function ProjectPageView() {
const slugs = researchProjects.map((p) => p.slug);
const trackId = "research-projects-track";
return (
<section className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 border-b border-line pb-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="h-px w-8 bg-vermillion"></span>
<span className="font-mono text-xs tracking-widest uppercase text-vermillion font-bold">
Key Initiatives
</span>
</div>
<h2 className="font-serif text-2xl sm:text-3xl font-normal text-ink">
(Research Projects)
</h2>
</div>
<p className="text-xs text-ink-mute font-mono">
{researchProjects.length} /
</p>
</div>
{/* Scroll-Snap Track: Mobile 1up, Tablet 2up, Desktop Static 3-col Grid */}
<div
id={trackId}
tabIndex={0}
role="group"
aria-roledescription="carousel"
aria-label="연구 프로젝트 목록"
className="flex gap-6 overflow-x-auto snap-x snap-mandatory scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden desktop:grid desktop:grid-cols-3 desktop:overflow-visible focus:outline-none focus:ring-1 focus:ring-vermillion/50 rounded"
>
{researchProjects.map((proj, idx) => (
<article
key={proj.slug}
id={`project-${proj.slug}`}
role="group"
aria-roledescription="slide"
aria-label={`${researchProjects.length}개 중 ${idx + 1}번째 프로젝트`}
className="snap-start shrink-0 min-w-0 w-full md:w-[calc(50%-12px)] desktop:w-full flex flex-col justify-between bg-paper p-6 rounded-lg border border-line hover:border-vermillion/60 transition-colors space-y-5 break-words [word-break:keep-all]"
>
<div className="space-y-4">
{/* Header Info */}
<div className="flex items-start justify-between gap-3 border-b border-line/60 pb-3">
<h3 className="font-serif text-xl font-normal text-ink leading-snug">
{proj.title}
</h3>
{proj.period && (
<span className="font-mono text-xs text-vermillion bg-ivory border border-line px-2 py-0.5 rounded shrink-0">
{proj.period}
</span>
)}
</div>
{/* Abstract */}
<p className="text-sm leading-relaxed text-ink/80">
{proj.abstract}
</p>
{/* Keywords */}
<div className="flex flex-wrap gap-1.5 pt-1">
{proj.keywords.map((kw) => (
<span
key={kw}
className="font-mono text-[0.72rem] text-ink-mute bg-ivory border border-line px-2 py-0.5 rounded"
>
#{kw}
</span>
))}
</div>
</div>
{/* Metadata Footer: Standards Track & Deliverables */}
<div className="space-y-3 pt-3 border-t border-line/60 text-xs font-mono">
{/* Standards Track (or Funder if present, never placeholder if undefined) */}
{proj.funder ? (
<div className="flex items-baseline justify-between gap-2">
<span className="text-ink-mute">Funder:</span>
<span className="text-ink font-semibold">{proj.funder}</span>
</div>
) : proj.standardsTrack ? (
<div className="flex items-baseline justify-between gap-2">
<span className="text-ink-mute">Standards Track:</span>
<span className="text-cobalt font-bold">{proj.standardsTrack}</span>
</div>
) : proj.standardsOrg ? (
<div className="flex items-baseline justify-between gap-2">
<span className="text-ink-mute">Organization:</span>
<span className="text-cobalt font-bold">{proj.standardsOrg}</span>
</div>
) : null}
{/* Key Deliverables */}
{proj.deliverables.length > 0 && (
<div className="space-y-1.5 pt-1">
<span className="text-ink-mute block">Key Deliverables:</span>
<ul className="space-y-1">
{proj.deliverables.slice(0, 3).map((item, dIdx) => (
<li
key={dIdx}
className="flex items-start gap-1.5 text-[0.75rem] text-ink/90"
>
<span className="text-vermillion font-bold"></span>
<span className="line-clamp-2 leading-tight">
<strong className="font-semibold text-ink">{item.ref}</strong> {item.title}
</span>
</li>
))}
{proj.deliverables.length > 3 && (
<li className="text-[0.7rem] text-ink-mute italic">
+ {proj.deliverables.length - 3}
</li>
)}
</ul>
</div>
)}
</div>
</article>
))}
</div>
{/* Interactive Controls (Hidden on desktop) */}
<ProjectPageViewControls slugs={slugs} trackId={trackId} />
</section>
);
}
export default ProjectPageView;
@@ -0,0 +1,122 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
interface ProjectPageViewControlsProps {
slugs: string[];
trackId: string;
}
export default function ProjectPageViewControls({
slugs,
trackId,
}: ProjectPageViewControlsProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
const track = document.getElementById(trackId);
if (!track) return;
const checkScroll = () => {
const { scrollLeft, scrollWidth, clientWidth } = track;
setCanScrollLeft(scrollLeft > 5);
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 5);
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
const index = slugs.findIndex((s) => `project-${s}` === id);
if (index !== -1) {
setActiveIndex(index);
}
}
});
},
{ root: track, threshold: 0.6 }
);
slugs.forEach((slug) => {
const el = document.getElementById(`project-${slug}`);
if (el) observer.observe(el);
});
track.addEventListener("scroll", checkScroll, { passive: true });
checkScroll();
return () => {
observer.disconnect();
track.removeEventListener("scroll", checkScroll);
};
}, [slugs, trackId]);
const scroll = (direction: "left" | "right") => {
const track = document.getElementById(trackId);
if (!track) return;
const cardWidth = track.firstElementChild?.clientWidth || 300;
const delta = direction === "left" ? -(cardWidth + 24) : cardWidth + 24;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
track.scrollBy({
left: delta,
behavior: prefersReducedMotion ? "auto" : "smooth",
});
};
return (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-line desktop:hidden">
{/* Indicator dots using semantic anchor links */}
<div className="flex items-center gap-2" role="tablist" aria-label="프로젝트 위치">
{slugs.map((slug, idx) => (
<a
key={slug}
href={`#project-${slug}`}
role="tab"
aria-selected={activeIndex === idx}
aria-label={`${slugs.length}개 중 ${idx + 1}번째 프로젝트로 이동`}
className={`h-2.5 rounded-full transition-all duration-300 ${
activeIndex === idx
? "w-8 bg-vermillion"
: "w-2.5 bg-line hover:bg-ink-mute"
}`}
/>
))}
</div>
{/* Navigation Buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => scroll("left")}
disabled={!canScrollLeft}
aria-label="이전 프로젝트"
aria-controls={trackId}
className="inline-flex items-center justify-center w-9 h-9 border border-line bg-paper text-ink transition hover:border-vermillion hover:text-vermillion disabled:opacity-30 disabled:pointer-events-none rounded"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
<button
type="button"
onClick={() => scroll("right")}
disabled={!canScrollRight}
aria-label="다음 프로젝트"
aria-controls={trackId}
className="inline-flex items-center justify-center w-9 h-9 border border-line bg-paper text-ink transition hover:border-vermillion hover:text-vermillion disabled:opacity-30 disabled:pointer-events-none rounded"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
);
}