vzxipsule: Replace the featured rail with an auto-rotating FeaturedCarousel

This commit is contained in:
2026-10-19 22:30:16 -06:00
parent 5e391ae678
commit ce1a3e2eaa
11 changed files with 425 additions and 49 deletions
+2 -2
View File
@@ -3,14 +3,14 @@
import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null;
return (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<FeaturedCarousel experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} />
</>
);
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
import { toneFor } from "./HexAvatar";
import { featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia(REDUCED_MOTION);
setReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
function Star() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
</svg>
);
}
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
const name = expert.displayName || "Expert";
if (expert.pictureUrl) {
// eslint-disable-next-line @next/next/no-img-element
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
}
return (
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
<span aria-hidden="true">{initials(name)}</span>
</span>
);
}
export interface FeaturedCarouselProps {
experts: DiscoverExpert[];
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
size?: number;
}
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
const count = experts.length;
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
const autoplay = useRef<FeaturedAutoplay | null>(null);
const hovered = useRef(false);
useEffect(() => {
if (count < 2 || reduced) return;
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
if (hovered.current) ctl.pause();
autoplay.current = ctl;
return () => { ctl.stop(); autoplay.current = null; };
// The index only seeds a restart; ticking must not recreate the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, reduced]);
if (count === 0) return null;
const current = index % count;
const cap = experts[current];
const style = { "--cfw": `${size}px` } as CSSProperties;
return (
<section
className="tile panel rail rail-featured featured"
aria-labelledby="featured-title"
onMouseEnter={() => { hovered.current = true; autoplay.current?.pause(); }}
onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }}
>
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
<div className="featured-cf" style={style} aria-live="polite" data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
return (
<div
key={String(e.id)}
className={`featured-item featured-${slot}`}
aria-hidden={slot === "c0" ? undefined : true}
data-testid="featured-item"
>
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
</div>
);
})}
</div>
<div className="featured-cap" key={current}>
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
{cap.rating !== null || cap.reviewsCount !== null ? (
<span className="featured-rate">
{cap.rating !== null ? (
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
) : null}
{cap.reviewsCount !== null ? (
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
) : null}
</span>
) : null}
</div>
{count > 1 ? (
<div className="featured-dots" aria-hidden="true">
{experts.map((e, i) => (
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
))}
</div>
) : <div className="featured-dots-gap" />}
</section>
);
}
-35
View File
@@ -1,35 +0,0 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverExpert } from "@/lib/discover";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
return (
<span className="rail-meta">
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
</span>
);
}
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="tile panel rail rail-featured" aria-labelledby="rail-experts">
<div className="panel-head">
<h2 id="rail-experts" className="type-title type-title-strong">Featured experts</h2>
</div>
<ul className="rail-list panel-scroll fade-edges">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
<Rating rating={e.rating} reviews={e.reviewsCount} />
</div>
</li>
))}
</ul>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
/** Rotation timing from the spec ("Featured experts carousel"). */
export const FEATURED_INTERVAL_MS = 2800;
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
/**
* Where item `i` sits when `current` is in the center: by its signed offset
* from the current index, wrapped so the shorter way round wins. With two
* items the other one is a single right neighbor, never an empty pair.
*/
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
let off = (((i - current) % count) + count) % count;
if (off > count / 2) off -= count;
if (off === 0) return "c0";
if (off === 1) return "r1";
if (off === -1) return "l1";
if (off === 2) return "r2";
if (off === -2) return "l2";
return off > 0 ? "r3" : "l3";
}
export interface FeaturedAutoplay {
readonly index: number;
readonly paused: boolean;
pause(): void;
resume(): void;
stop(): void;
}
/**
* Advances the index every `intervalMs` while not paused. A single item never
* rotates. The caller stops it on unmount.
*/
export function startFeaturedAutoplay(opts: {
count: number;
onChange: (index: number) => void;
intervalMs?: number;
start?: number;
}): FeaturedAutoplay {
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
let index = opts.start ?? 0;
let paused = false;
const timer =
count > 1
? setInterval(() => {
if (paused) return;
index = (index + 1) % count;
onChange(index);
}, intervalMs)
: null;
return {
get index() { return index; },
get paused() { return paused; },
pause() { paused = true; },
resume() { paused = false; },
stop() { if (timer) clearInterval(timer); },
};
}