Promote to main: Feed browser luxury redesign #7
@@ -0,0 +1,11 @@
|
||||
// Dev-only preview of the signed-in feed shell with fixture data (no Minnect
|
||||
// login, no network). Not linked from the app and returns 404 in production.
|
||||
import { notFound } from "next/navigation";
|
||||
import { FeedPreview } from "@/dev/FeedPreview";
|
||||
|
||||
export const metadata = { title: "Feed (dev)" };
|
||||
|
||||
export default function FeedDevPage() {
|
||||
if (process.env.NODE_ENV === "production") notFound();
|
||||
return <FeedPreview />;
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
--danger: #FF6B70;
|
||||
--glass: rgba(18,18,21,.72);
|
||||
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
|
||||
/* Panels (bento tiles): a faint top highlight and a soft drop. */
|
||||
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 0 0 1px rgba(255,255,255,.02), 0 24px 44px -24px rgba(0,0,0,.8);
|
||||
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
|
||||
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
|
||||
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
|
||||
@@ -42,6 +44,7 @@
|
||||
--danger: #B3121A;
|
||||
--glass: rgba(251,250,248,.74);
|
||||
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
|
||||
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 1px 2px rgba(26,28,31,.04), 0 16px 36px -20px rgba(26,28,31,.22);
|
||||
--shadow-btn: 0 10px 20px -6px rgba(26,28,31,.22);
|
||||
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
|
||||
--neu-d: rgba(26,28,31,.17);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
|
||||
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
import "../styles/type.css";
|
||||
import "../styles/layout.css";
|
||||
import "../styles/buttons.css";
|
||||
import "../styles/avatar.css";
|
||||
import "../styles/signin.css";
|
||||
|
||||
+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 (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
// Dev-only: the real signed-in shell with fixture data and no network calls.
|
||||
// Likes, search and the composer work on local state so the panels can be
|
||||
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||
import { FeedView } from "@/components/FeedView";
|
||||
import { toCardPosts, type CardPost } from "@/lib/feed-model";
|
||||
import {
|
||||
FIXTURE_CATEGORIES,
|
||||
FIXTURE_DISCOVER,
|
||||
FIXTURE_FEED_ITEMS,
|
||||
FIXTURE_NOW,
|
||||
FIXTURE_PERSON,
|
||||
FIXTURE_SEARCH_RESULTS,
|
||||
} from "./fixtures";
|
||||
|
||||
export function FeedPreview() {
|
||||
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
|
||||
const [draft, setDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
|
||||
// With no query or category, show every fixture result so the list scrolls.
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
|
||||
}, [query]);
|
||||
|
||||
const toggleLike = (post: CardPost) =>
|
||||
setPosts((cur) =>
|
||||
cur.map((p) =>
|
||||
p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
person={FIXTURE_PERSON}
|
||||
search={
|
||||
<ExpertSearchView
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
categories={FIXTURE_CATEGORIES}
|
||||
activeCategory={category}
|
||||
onCategory={setCategory}
|
||||
status="ready"
|
||||
results={results}
|
||||
/>
|
||||
}
|
||||
feed={
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status="ready"
|
||||
loadingMore={false}
|
||||
loadMoreFailed={false}
|
||||
newCount={0}
|
||||
onRetry={() => {}}
|
||||
onShowNew={() => {}}
|
||||
onToggleLike={toggleLike}
|
||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||
composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }}
|
||||
now={FIXTURE_NOW}
|
||||
/>
|
||||
}
|
||||
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Fixture data for the dev-only preview routes (/dev/feed). Nothing here is
|
||||
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
|
||||
// so the preview never calls the network. Later tasks extend this file.
|
||||
import type { Person } from "@/lib/auth-client";
|
||||
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import type { ExpertResult } from "@/lib/experts-search";
|
||||
|
||||
/** Fixed "now" so relative times in the preview are stable. */
|
||||
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||
|
||||
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
|
||||
export function placeholderImage(hue: number, label = ""): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
|
||||
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
|
||||
(label
|
||||
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
|
||||
: "") +
|
||||
`</svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export const FIXTURE_FEED_USER_ID = "dev-user-1";
|
||||
|
||||
export const FIXTURE_PERSON: Person = {
|
||||
name: "Jordan Rivera",
|
||||
email: "jordan.rivera@example.com",
|
||||
pictureUrl: null,
|
||||
feedUserId: FIXTURE_FEED_USER_ID,
|
||||
};
|
||||
|
||||
const author = (id: string, displayName: string, picture: string | null = null) => ({
|
||||
id,
|
||||
displayName,
|
||||
profilePictureUrl: picture,
|
||||
});
|
||||
|
||||
/** Raw feed items in the shape /api/feed returns; run them through toCardPosts. */
|
||||
export const FIXTURE_FEED_ITEMS: unknown[] = [
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T11:42:00Z",
|
||||
likesCount: 4,
|
||||
commentsCount: 1,
|
||||
post: {
|
||||
id: "p1",
|
||||
content: "Shipped the new onboarding flow this morning. The best feedback so far came from a five minute call, not a survey.",
|
||||
author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T10:15:00Z",
|
||||
likesCount: 128,
|
||||
commentsCount: 23,
|
||||
isLiked: true,
|
||||
post: {
|
||||
id: "p2",
|
||||
content: "Three questions I ask before every pricing change:\n• Who pays more?\n• Who leaves?\n• What do we learn either way?",
|
||||
author: author("a2", "Priya Natarajan"),
|
||||
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(352, "Pricing review"), order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T08:30:00Z",
|
||||
likesCount: 56,
|
||||
commentsCount: 7,
|
||||
post: {
|
||||
id: "p3",
|
||||
content: "A short walkthrough of how we run weekly reviews with a remote team.",
|
||||
author: author("a3", "Marcus Bell"),
|
||||
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T19:05:00Z",
|
||||
likesCount: 9,
|
||||
commentsCount: 0,
|
||||
post: {
|
||||
id: "p4",
|
||||
content: "Reading list for anyone moving from engineering into product: start with the customer interviews, then the numbers.",
|
||||
author: author("a4", "Elena Kovac"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T14:40:00Z",
|
||||
likesCount: 312,
|
||||
commentsCount: 41,
|
||||
post: {
|
||||
id: "p5",
|
||||
content: "Fundraising tip: send the update even when the month was bad. Especially when the month was bad.",
|
||||
author: author("a5", "Samuel Okafor"),
|
||||
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(30, "Monthly update"), order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-17T09:00:00Z",
|
||||
likesCount: 17,
|
||||
commentsCount: 3,
|
||||
post: {
|
||||
id: "p6",
|
||||
content: "Your first ten customers will tell you what the product is. Listen before you build the next feature.",
|
||||
author: author("a6", "Hannah Lindqvist"),
|
||||
mediaItems: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({
|
||||
id,
|
||||
slug: displayName.replace(/\s+/g, ""),
|
||||
displayName,
|
||||
byline,
|
||||
pictureUrl: null,
|
||||
rating,
|
||||
reviewsCount,
|
||||
available: true,
|
||||
location: { city: null, country: null },
|
||||
});
|
||||
|
||||
export const FIXTURE_FEATURED: DiscoverExpert[] = [
|
||||
expert("e1", "Priya Natarajan", "Pricing and growth advisor", 4.9, 212),
|
||||
expert("e2", "Marcus Bell", "Remote team coach", 4.8, 96),
|
||||
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
|
||||
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
|
||||
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
|
||||
];
|
||||
|
||||
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
|
||||
id,
|
||||
question,
|
||||
views,
|
||||
thumbnailUrl: placeholderImage(hue),
|
||||
media: null,
|
||||
expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating },
|
||||
});
|
||||
|
||||
export const FIXTURE_TRENDING: DiscoverMinnect[] = [
|
||||
minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352),
|
||||
minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30),
|
||||
minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210),
|
||||
minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270),
|
||||
minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150),
|
||||
];
|
||||
|
||||
export const FIXTURE_CATEGORIES: DiscoverCategory[] = [
|
||||
{ id: 1, name: "Business", icon: null, sortOrder: 1 },
|
||||
{ id: 2, name: "Coaching", icon: null, sortOrder: 2 },
|
||||
{ id: 3, name: "Finance", icon: null, sortOrder: 3 },
|
||||
{ id: 4, name: "Product", icon: null, sortOrder: 4 },
|
||||
{ id: 5, name: "Media", icon: null, sortOrder: 5 },
|
||||
];
|
||||
|
||||
export const FIXTURE_DISCOVER: DiscoverData = {
|
||||
featuredExperts: FIXTURE_FEATURED,
|
||||
trendingMinnects: FIXTURE_TRENDING,
|
||||
categories: FIXTURE_CATEGORIES,
|
||||
};
|
||||
|
||||
/** Find experts results: every featured expert plus a few more, filterable by name. */
|
||||
export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [
|
||||
...FIXTURE_FEATURED,
|
||||
expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21),
|
||||
expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77),
|
||||
expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18),
|
||||
].map((e) => ({
|
||||
id: e.id,
|
||||
slug: e.slug,
|
||||
displayName: e.displayName,
|
||||
byline: e.byline,
|
||||
pictureUrl: e.pictureUrl,
|
||||
rating: e.rating,
|
||||
categories: [],
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Signed-in shell (spec "Desktop and tablet layout", ADR 0001 sections 3 and 6).
|
||||
.app is the size container; .bento is its child, so container queries can
|
||||
style it. The page never scrolls: only the panels' own lists do. */
|
||||
|
||||
.app {
|
||||
container: app / inline-size;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
transition: background-color .45s, color .45s;
|
||||
}
|
||||
|
||||
/* Below 1001px the tablet and phone tasks own the layout. Until then the
|
||||
panels stack in one column that scrolls inside the app frame. */
|
||||
.bento {
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bento-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 1001px to 1180px: three columns, narrower sides. */
|
||||
@container app (min-width: 1001px) {
|
||||
.bento {
|
||||
display: grid;
|
||||
grid-template-columns: 244px minmax(0, 1fr) 292px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bento-col { height: 100%; }
|
||||
}
|
||||
|
||||
/* 1181px and wider. */
|
||||
@container app (min-width: 1181px) {
|
||||
.bento { grid-template-columns: 272px minmax(0, 1fr) 324px; }
|
||||
}
|
||||
|
||||
/* Panels ----------------------------------------------------------------- */
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
background: var(--tile);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-panel);
|
||||
box-shadow: var(--shadow-tile);
|
||||
transition: background-color .45s, border-color .45s;
|
||||
}
|
||||
|
||||
.panel { display: flex; flex-direction: column; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
.panel-head h2 { margin: 0; font-size: 21px; }
|
||||
|
||||
/* A list that scrolls inside its panel. */
|
||||
.panel-scroll {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-2) transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Faded top and bottom edges on a scrolling list. The mask color is only an
|
||||
alpha source, it is never painted. */
|
||||
.fade-edges {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
|
||||
/* Which panels grow to fill their column. */
|
||||
.bento-left .expert-search { flex: 1 1 0; }
|
||||
.bento-center .feed { flex: 1 1 0; }
|
||||
.bento-right { justify-content: flex-start; }
|
||||
.bento-right .rail-featured { flex: 0 1 auto; max-height: 40%; }
|
||||
.bento-right .rail-trending { flex: 1 1 0; }
|
||||
|
||||
/* Panel contents keep their inner padding off the scroll edge. */
|
||||
.expert-search .search-form { margin: 0 14px; display: flex; flex-direction: column; }
|
||||
.expert-search .search-form input { width: 100%; }
|
||||
.expert-search .chips { padding: 0 14px; }
|
||||
.expert-search .search-results { padding: 12px 14px 24px; }
|
||||
.rail .rail-list { padding: 4px 14px 24px; }
|
||||
.feed .composer { margin: 0 14px 4px; }
|
||||
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
|
||||
|
||||
/* Single-column fallback (below 1001px): lists get a usable height. */
|
||||
@container app (max-width: 1000px) {
|
||||
.bento-col .panel { flex: 0 0 auto; }
|
||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
||||
.bento-right .rail-featured { max-height: none; }
|
||||
}
|
||||
|
||||
/* Account panel ---------------------------------------------------------- */
|
||||
|
||||
.account { flex: none; }
|
||||
|
||||
.account-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 12px 16px;
|
||||
}
|
||||
.account-word { flex: 1; font-size: 15px; }
|
||||
|
||||
.account-theme {
|
||||
appearance: none;
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-ctl);
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, color .2s, transform .15s;
|
||||
}
|
||||
.account-theme:hover { background: var(--tile-2); color: var(--ink); }
|
||||
.account-theme:active { transform: scale(.9); }
|
||||
.account-theme svg { width: 20px; height: 20px; }
|
||||
|
||||
.account-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 12px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--tile-2);
|
||||
}
|
||||
.account-me-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.account-me-text strong { font-size: 14px; }
|
||||
.account-me-text span {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.account-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: account-ping 2.2s infinite;
|
||||
}
|
||||
@keyframes account-ping {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 45%); }
|
||||
70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent), transparent 100%); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.account-dot { animation: none !important; }
|
||||
.panel-scroll { scroll-behavior: auto !important; }
|
||||
}
|
||||
+2
-34
@@ -9,12 +9,6 @@ input { font: inherit; padding: 12px; border: 1px solid var(--line-2); border-ra
|
||||
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
|
||||
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
|
||||
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--tile); border-bottom: 1px solid var(--line); }
|
||||
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
|
||||
.shell header span { color: var(--ink-3); }
|
||||
.shell header { position: sticky; top: 0; z-index: 5; }
|
||||
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: transparent; color: var(--ink); border: 1px solid var(--ink); }
|
||||
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.post { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.post-head { display: flex; align-items: center; gap: 12px; }
|
||||
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
|
||||
@@ -31,7 +25,7 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
||||
.feed-center { display: grid; place-items: center; padding: 16px; }
|
||||
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||
.sentinel { height: 1px; }
|
||||
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 14px; font-weight: 400; }
|
||||
.like-btn:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); }
|
||||
@@ -77,16 +71,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
||||
.menu .menu-danger { color: var(--danger); }
|
||||
.menu-ask { font-size: 13px; color: var(--ink-3); 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: var(--bg); }
|
||||
.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: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
|
||||
.expert-card { align-items: center; }
|
||||
@@ -100,25 +84,9 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
||||
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.minnect-video { background: black; border-radius: 0; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
|
||||
.expert-search { margin-top: 16px; }
|
||||
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
|
||||
.expert-search form { display: flex; flex-direction: column; }
|
||||
.expert-search input { border-radius: 0; width: 100%; }
|
||||
/* Category chips (Find experts panel). Squared corners, monochrome + red. */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: transparent; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
|
||||
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.search-results { margin-top: 12px; }
|
||||
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
|
||||
@media (max-width: 959px) {
|
||||
.col-left .expert-search { flex-basis: 100%; }
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
|
||||
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
|
||||
renderToStaticMarkup(createElement(ExpertSearchView, {
|
||||
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
|
||||
activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o,
|
||||
activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o,
|
||||
}));
|
||||
|
||||
describe("ExpertSearchView", () => {
|
||||
|
||||
Reference in New Issue
Block a user