rnxcmkidd: Add the viewport-fit bento shell and a dev feed preview with fixtures
This commit is contained in:
+7
-25
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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,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 (
|
||||
|
||||
Reference in New Issue
Block a user