vzxipsule: Replace the featured rail with an auto-rotating FeaturedCarousel
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user