gm4ebnr3j: Add three-column layout with featured experts and trending minnects rails

This commit is contained in:
2026-10-19 17:28:42 -06:00
parent 6201fb0d28
commit b97a77471b
7 changed files with 267 additions and 1 deletions
+15 -1
View File
@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
@@ -46,7 +48,19 @@ export function App() {
<span>{state.person.email}</span>
</div>
</header>
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
<div className="columns">
<nav className="col-left" aria-label="Main">
<ul>
<li><Link href="/" aria-current="page">Feed</Link></li>
</ul>
</nav>
<div className="col-center">
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</div>
<aside className="col-right" aria-label="Discover">
<DiscoverRails />
</aside>
</div>
</main>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null;
return (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} />
</>
);
}
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
export function DiscoverRails() {
const [data, setData] = useState<DiscoverData | null>(null);
useEffect(() => {
let live = true;
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
return () => { live = false; };
}, []);
return <DiscoverRailsView data={data} />;
}
+38
View File
@@ -0,0 +1,38 @@
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
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="rail" aria-labelledby="rail-experts">
<h2 id="rail-experts">Featured experts</h2>
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { DiscoverMinnect } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-trending">
<h2 id="rail-trending">Trending minnects</h2>
<ul className="rail-list">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
{m.expert.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
) : (
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
)}
<strong className="rail-name">{who}</strong>
</div>
{m.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={m.thumbnailUrl} alt={m.question ? `Thumbnail for: ${m.question}` : "Minnect thumbnail"} />
) : null}
{m.question ? <p className="minnect-question">{m.question}</p> : null}
{m.views !== null ? (
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
) : null}
{m.audio.url ? (
<audio className="minnect-audio" controls preload="none" src={m.audio.url} aria-label={`Play answer from ${who}`} />
) : null}
</li>
);
})}
</ul>
</section>
);
}