gm4ebnr3j: Add three-column layout with featured experts and trending minnects rails
This commit is contained in:
@@ -79,3 +79,35 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.menu button:hover { background: #f4f4f4; }
|
||||
.menu .menu-danger { color: #a8141b; }
|
||||
.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; }
|
||||
|
||||
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
|
||||
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
|
||||
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
|
||||
.col-left ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
|
||||
.col-left a[aria-current="page"] { background: var(--ink); color: #fff; }
|
||||
.col-center { flex: 1 1 400px; min-width: 0; }
|
||||
.col-center .feed { padding-left: 0; padding-right: 0; }
|
||||
.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
|
||||
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rail-card { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
|
||||
.expert-card { align-items: center; }
|
||||
.minnect-card { flex-direction: column; }
|
||||
.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; }
|
||||
.rail-name { font-size: 14px; }
|
||||
.rail-byline { color: var(--grey); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-meta { color: var(--grey); font-size: 13px; }
|
||||
.minnect-who { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
|
||||
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
|
||||
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.minnect-audio { width: 100%; height: 36px; }
|
||||
@media (max-width: 959px) {
|
||||
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
||||
.col-left ul { flex-direction: row; }
|
||||
.col-center { flex: 1 1 100%; order: 3; }
|
||||
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
|
||||
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
|
||||
.col-right .rail-card { flex: 0 0 260px; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Browser-side reader for the same-origin GET /api/discover route.
|
||||
import type { DiscoverData } from "./discover";
|
||||
|
||||
export class DiscoverLoadError extends Error {
|
||||
constructor() {
|
||||
super("discover_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
const arr = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
|
||||
|
||||
export async function fetchDiscover(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscoverData> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl("/api/discover", { credentials: "same-origin" });
|
||||
} catch {
|
||||
throw new DiscoverLoadError();
|
||||
}
|
||||
if (!res.ok) throw new DiscoverLoadError();
|
||||
try {
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
return {
|
||||
featuredExperts: arr(body.featuredExperts),
|
||||
trendingMinnects: arr(body.trendingMinnects),
|
||||
categories: arr(body.categories),
|
||||
};
|
||||
} catch {
|
||||
throw new DiscoverLoadError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
|
||||
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { fetchDiscover } from "@/lib/discover-client";
|
||||
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
|
||||
const expert = (o: Partial<DiscoverExpert> = {}): DiscoverExpert => ({
|
||||
id: 1, slug: "ann", displayName: "Ann Lee", byline: "Growth coach", pictureUrl: "https://x/a.jpg",
|
||||
rating: 4.8, reviewsCount: 12, available: true, location: { city: null, country: null }, ...o,
|
||||
});
|
||||
const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
||||
id: 9, question: "How do I price?", views: 1234, thumbnailUrl: "https://x/t.jpg",
|
||||
audio: { url: "https://x/a.mp3", duration: 30, waveform: null },
|
||||
expert: { slug: "ann", displayName: "Ann Lee", pictureUrl: null, byline: null, rating: null }, ...o,
|
||||
});
|
||||
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||
|
||||
describe("FeaturedExpertsRail", () => {
|
||||
it("renders a card with name, byline, rating and reviews", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
|
||||
expect(out).toContain("<h2");
|
||||
expect(out).toContain("Ann Lee");
|
||||
expect(out).toContain("Growth coach");
|
||||
expect(out).toContain("4.8");
|
||||
expect(out).toContain("12 reviews");
|
||||
expect(out).toContain("<img");
|
||||
});
|
||||
it("falls back to initials and omits missing fields", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, {
|
||||
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
|
||||
}));
|
||||
expect(out).not.toContain("<img");
|
||||
expect(out).toContain("AL");
|
||||
expect(out).not.toContain("review");
|
||||
});
|
||||
it("renders nothing when empty", () => {
|
||||
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrendingMinnectsRail", () => {
|
||||
it("renders question, views, thumbnail and an audio control", () => {
|
||||
const out = html(createElement(TrendingMinnectsRail, { minnects: [minnect()] }));
|
||||
expect(out).toContain("How do I price?");
|
||||
expect(out).toContain("1,234 views");
|
||||
expect(out).toContain("<audio");
|
||||
expect(out).toContain("controls");
|
||||
expect(out).toContain('src="https://x/a.mp3"');
|
||||
expect(out).toContain("t.jpg");
|
||||
});
|
||||
it("has no audio element or thumbnail when those are missing", () => {
|
||||
const out = html(createElement(TrendingMinnectsRail, {
|
||||
minnects: [minnect({ audio: { url: null, duration: null, waveform: null }, thumbnailUrl: null, question: null, views: null })],
|
||||
}));
|
||||
expect(out).not.toContain("<audio");
|
||||
expect(out).not.toContain("minnect-thumb");
|
||||
expect(out).toContain("Ann Lee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscoverRailsView / fetchDiscover", () => {
|
||||
it("renders both rails from data and nothing when null", () => {
|
||||
const data = { featuredExperts: [expert()], trendingMinnects: [minnect()], categories: [] };
|
||||
const out = html(createElement(DiscoverRailsView, { data }));
|
||||
expect(out).toContain("Featured experts");
|
||||
expect(out).toContain("Trending minnects");
|
||||
expect(html(createElement(DiscoverRailsView, { data: null }))).toBe("");
|
||||
});
|
||||
it("fetches /api/discover and tolerates missing arrays", async () => {
|
||||
const f = (async () => new Response(JSON.stringify({ featuredExperts: [expert()] }), { status: 200 })) as typeof fetch;
|
||||
const d = await fetchDiscover(f);
|
||||
expect(d.featuredExperts).toHaveLength(1);
|
||||
expect(d.trendingMinnects).toEqual([]);
|
||||
});
|
||||
it("throws on a 502", async () => {
|
||||
const f = (async () => new Response("{}", { status: 502 })) as typeof fetch;
|
||||
await expect(fetchDiscover(f)).rejects.toThrow("discover_unavailable");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user