rnxcmkidd: Add the viewport-fit bento shell and a dev feed preview with fixtures

This commit is contained in:
2026-10-19 22:08:21 -06:00
parent 9aeb9a379f
commit 89064e88c1
15 changed files with 638 additions and 120 deletions
+7 -25
View File
@@ -1,10 +1,9 @@
"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 { AppShell } from "./AppShell";
import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
@@ -41,28 +40,11 @@ export function App() {
}} />;
}
return (
<main className="shell">
<header>
<Logo size={32} />
<div>
<strong>{state.person.name}</strong>
<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>
<ExpertSearch />
</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>
<AppShell
person={state.person}
search={<ExpertSearch />}
feed={<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />}
discover={<DiscoverRails />}
/>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import type { ReactNode } from "react";
import type { Person } from "@/lib/auth-client";
import { useTheme } from "@/lib/use-theme";
import { HexAvatar } from "./HexAvatar";
import { Logo } from "./Logo";
const iconProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
function SunIcon() {
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
function MoonIcon() {
return (
<svg {...iconProps}>
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
</svg>
);
}
/** Left account panel: logo, wordmark, theme toggle, the person, live status. */
export function AccountPanel({ person }: { person: Person }) {
const { theme, toggleTheme } = useTheme();
const next = theme === "dark" ? "light" : "dark";
return (
<section className="tile account" aria-label="Account">
<div className="account-brand">
<Logo size={32} />
<span className="account-word type-wordmark">MINNECT</span>
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
</div>
<div className="account-me">
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
<div className="account-me-text">
<strong>{person.name}</strong>
<span>{person.email}</span>
</div>
</div>
<div className="account-live">
<span className="account-dot" aria-hidden="true" />
Feed updates live
</div>
</section>
);
}
export interface AppShellProps {
person: Person;
/** Find experts panel (left column, under the account panel). */
search: ReactNode;
/** Feed panel (center column). */
feed: ReactNode;
/** Featured experts and Trending minnects panels (right column). */
discover: ReactNode;
}
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
export function AppShell({ person, search, feed, discover }: AppShellProps) {
return (
<div className="app">
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
{search}
</div>
<div className="bento-col bento-center">{feed}</div>
<aside className="bento-col bento-right" aria-label="Discover">
{discover}
</aside>
</main>
</div>
);
}
+7 -10
View File
@@ -17,14 +17,15 @@ export interface ExpertSearchViewProps {
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
<details className="expert-search" open={p.defaultOpen}>
<summary>Find experts</summary>
<form role="search" onSubmit={(e) => e.preventDefault()}>
<section className="tile panel expert-search" aria-labelledby="h-find">
<div className="panel-head">
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
</div>
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
<label htmlFor="expert-q">Search experts</label>
<input
id="expert-q"
@@ -51,7 +52,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
))}
</div>
) : null}
<div aria-live="polite" className="search-results">
<div aria-live="polite" className="search-results panel-scroll fade-edges">
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
{p.status === "ready" && p.results.length === 0 ? (
@@ -72,7 +73,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
</ul>
) : null}
</div>
</details>
</section>
);
}
@@ -84,11 +85,9 @@ export function ExpertSearch() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [results, setResults] = useState<ExpertResult[]>([]);
const [open, setOpen] = useState(true);
const seq = useRef(0);
useEffect(() => {
setOpen(window.matchMedia("(min-width: 960px)").matches);
let live = true;
fetchDiscover().then(
(d) => { if (live) setCategories(d.categories); },
@@ -117,8 +116,6 @@ export function ExpertSearch() {
return (
<ExpertSearchView
key={open ? "open" : "closed"}
defaultOpen={open}
query={query}
onQuery={setQuery}
categories={categories}
+5 -3
View File
@@ -14,9 +14,11 @@ function Rating({ rating, reviews }: { rating: number | null; reviews: number |
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">
<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" />
+5 -2
View File
@@ -29,6 +29,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
// re-checks the feed instead of creating a duplicate.
const unconfirmed = useRef<{ id: string | null }>({ id: null });
const sentinel = useRef<HTMLDivElement | null>(null);
const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
@@ -93,7 +94,8 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: "400px" },
// The list scrolls inside its panel, so it is the observer root.
{ root: list.current, rootMargin: "400px" },
);
io.observe(el);
return () => io.disconnect();
@@ -224,7 +226,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
window.scrollTo({ top: 0, behavior: "smooth" });
list.current?.scrollTo({ top: 0, behavior: "smooth" });
}
return (
@@ -246,6 +248,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
sentinelRef={sentinel}
listRef={list}
/>
);
}
+57 -42
View File
@@ -1,4 +1,4 @@
import type { Ref } from "react";
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { PostCard } from "./PostCard";
@@ -24,6 +24,8 @@ export interface FeedViewProps {
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
/** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>;
now?: number;
}
@@ -31,63 +33,76 @@ function Spinner() {
return <span className="spinner" role="status" aria-label="Loading" />;
}
function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) {
return (
<section className="tile panel feed" aria-labelledby="h-feed">
<div className="panel-head feed-head">
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
</div>
{composer}
{children}
</section>
);
}
export function FeedView(p: FeedViewProps) {
if (p.status === "loading") {
return (
<section className="feed">
<FeedPanel>
<div className="feed-center">
<Spinner />
</div>
</section>
</FeedPanel>
);
}
if (p.status === "error") {
return (
<section className="feed">
<FeedPanel>
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
</section>
</FeedPanel>
);
}
return (
<section className="feed">
{p.composer ? <Composer {...p.composer} /> : null}
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</section>
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</div>
</FeedPanel>
);
}
+5 -3
View File
@@ -5,9 +5,11 @@ import { MinnectMedia } from "./MinnectMedia";
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">
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
<div className="panel-head">
<h2 id="rail-trending" className="type-title type-title-strong">Trending minnects</h2>
</div>
<ul className="rail-list panel-scroll fade-edges">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (