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;
|
--danger: #FF6B70;
|
||||||
--glass: rgba(18,18,21,.72);
|
--glass: rgba(18,18,21,.72);
|
||||||
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
|
--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. */
|
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
|
||||||
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
|
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
|
||||||
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
|
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
--danger: #B3121A;
|
--danger: #B3121A;
|
||||||
--glass: rgba(251,250,248,.74);
|
--glass: rgba(251,250,248,.74);
|
||||||
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
|
--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-btn: 0 10px 20px -6px rgba(26,28,31,.22);
|
||||||
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
|
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
|
||||||
--neu-d: rgba(26,28,31,.17);
|
--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 { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import "../styles/type.css";
|
import "../styles/type.css";
|
||||||
|
import "../styles/layout.css";
|
||||||
import "../styles/buttons.css";
|
import "../styles/buttons.css";
|
||||||
import "../styles/avatar.css";
|
import "../styles/avatar.css";
|
||||||
import "../styles/signin.css";
|
import "../styles/signin.css";
|
||||||
|
|||||||
+7
-25
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { loadSession, type Person } from "@/lib/auth-client";
|
import { loadSession, type Person } from "@/lib/auth-client";
|
||||||
import { SignInForm } from "./SignInForm";
|
import { SignInForm } from "./SignInForm";
|
||||||
import { Logo } from "./Logo";
|
import { AppShell } from "./AppShell";
|
||||||
import { Feed } from "./Feed";
|
import { Feed } from "./Feed";
|
||||||
import { DiscoverRails } from "./DiscoverRails";
|
import { DiscoverRails } from "./DiscoverRails";
|
||||||
import { ExpertSearch } from "./ExpertSearch";
|
import { ExpertSearch } from "./ExpertSearch";
|
||||||
@@ -41,28 +40,11 @@ export function App() {
|
|||||||
}} />;
|
}} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<AppShell
|
||||||
<header>
|
person={state.person}
|
||||||
<Logo size={32} />
|
search={<ExpertSearch />}
|
||||||
<div>
|
feed={<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />}
|
||||||
<strong>{state.person.name}</strong>
|
discover={<DiscoverRails />}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
onCategory: (name: string | null) => void;
|
||||||
status: SearchStatus;
|
status: SearchStatus;
|
||||||
results: ExpertResult[];
|
results: ExpertResult[];
|
||||||
defaultOpen?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
export function ExpertSearchView(p: ExpertSearchViewProps) {
|
||||||
return (
|
return (
|
||||||
<details className="expert-search" open={p.defaultOpen}>
|
<section className="tile panel expert-search" aria-labelledby="h-find">
|
||||||
<summary>Find experts</summary>
|
<div className="panel-head">
|
||||||
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
<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>
|
<label htmlFor="expert-q">Search experts</label>
|
||||||
<input
|
<input
|
||||||
id="expert-q"
|
id="expert-q"
|
||||||
@@ -51,7 +52,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 === "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 === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
|
||||||
{p.status === "ready" && p.results.length === 0 ? (
|
{p.status === "ready" && p.results.length === 0 ? (
|
||||||
@@ -72,7 +73,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,11 +85,9 @@ export function ExpertSearch() {
|
|||||||
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
|
||||||
const [status, setStatus] = useState<SearchStatus>("idle");
|
const [status, setStatus] = useState<SearchStatus>("idle");
|
||||||
const [results, setResults] = useState<ExpertResult[]>([]);
|
const [results, setResults] = useState<ExpertResult[]>([]);
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const seq = useRef(0);
|
const seq = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(window.matchMedia("(min-width: 960px)").matches);
|
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchDiscover().then(
|
fetchDiscover().then(
|
||||||
(d) => { if (live) setCategories(d.categories); },
|
(d) => { if (live) setCategories(d.categories); },
|
||||||
@@ -117,8 +116,6 @@ export function ExpertSearch() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpertSearchView
|
<ExpertSearchView
|
||||||
key={open ? "open" : "closed"}
|
|
||||||
defaultOpen={open}
|
|
||||||
query={query}
|
query={query}
|
||||||
onQuery={setQuery}
|
onQuery={setQuery}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ function Rating({ rating, reviews }: { rating: number | null; reviews: number |
|
|||||||
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
|
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
|
||||||
if (experts.length === 0) return null;
|
if (experts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="rail" aria-labelledby="rail-experts">
|
<section className="tile panel rail rail-featured" aria-labelledby="rail-experts">
|
||||||
<h2 id="rail-experts">Featured experts</h2>
|
<div className="panel-head">
|
||||||
<ul className="rail-list">
|
<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) => (
|
{experts.map((e) => (
|
||||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
||||||
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
<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.
|
// re-checks the feed instead of creating a duplicate.
|
||||||
const unconfirmed = useRef<{ id: string | null }>({ id: null });
|
const unconfirmed = useRef<{ id: string | null }>({ id: null });
|
||||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||||
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
const busy = useRef(false);
|
const busy = useRef(false);
|
||||||
const shown = useRef<Set<string>>(new Set());
|
const shown = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -93,7 +94,8 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
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);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
@@ -224,7 +226,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
|||||||
function showNew() {
|
function showNew() {
|
||||||
setPosts((cur) => mergePosts(cur, pending));
|
setPosts((cur) => mergePosts(cur, pending));
|
||||||
setPending([]);
|
setPending([]);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
list.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,6 +248,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
|||||||
onSignedOut={onSignedOut}
|
onSignedOut={onSignedOut}
|
||||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
|
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
|
listRef={list}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||||
import { Composer, type ComposerProps } from "./Composer";
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
@@ -24,6 +24,8 @@ export interface FeedViewProps {
|
|||||||
deleteErrors?: ReadonlySet<string>;
|
deleteErrors?: ReadonlySet<string>;
|
||||||
composer?: ComposerProps;
|
composer?: ComposerProps;
|
||||||
sentinelRef?: Ref<HTMLDivElement>;
|
sentinelRef?: Ref<HTMLDivElement>;
|
||||||
|
/** The scrolling feed list; the page itself never scrolls. */
|
||||||
|
listRef?: Ref<HTMLDivElement>;
|
||||||
now?: number;
|
now?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,29 +33,41 @@ function Spinner() {
|
|||||||
return <span className="spinner" role="status" aria-label="Loading" />;
|
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) {
|
export function FeedView(p: FeedViewProps) {
|
||||||
if (p.status === "loading") {
|
if (p.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<section className="feed">
|
<FeedPanel>
|
||||||
<div className="feed-center">
|
<div className="feed-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FeedPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (p.status === "error") {
|
if (p.status === "error") {
|
||||||
return (
|
return (
|
||||||
<section className="feed">
|
<FeedPanel>
|
||||||
<div className="feed-message" role="alert">
|
<div className="feed-message" role="alert">
|
||||||
<p>The feed did not load.</p>
|
<p>The feed did not load.</p>
|
||||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FeedPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section className="feed">
|
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null}>
|
||||||
{p.composer ? <Composer {...p.composer} /> : null}
|
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
|
||||||
{p.newCount > 0 ? (
|
{p.newCount > 0 ? (
|
||||||
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
<button type="button" className="new-posts" onClick={p.onShowNew}>
|
||||||
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
|
||||||
@@ -88,6 +102,7 @@ export function FeedView(p: FeedViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
|
||||||
</section>
|
</div>
|
||||||
|
</FeedPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { MinnectMedia } from "./MinnectMedia";
|
|||||||
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
||||||
if (minnects.length === 0) return null;
|
if (minnects.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="rail" aria-labelledby="rail-trending">
|
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
|
||||||
<h2 id="rail-trending">Trending minnects</h2>
|
<div className="panel-head">
|
||||||
<ul className="rail-list">
|
<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) => {
|
{minnects.map((m) => {
|
||||||
const who = m.expert.displayName || "Expert";
|
const who = m.expert.displayName || "Expert";
|
||||||
return (
|
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; }
|
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; }
|
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; }
|
.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 { 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-head { display: flex; align-items: center; gap: 12px; }
|
||||||
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
|
.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; }
|
.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; }
|
.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); } }
|
@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; }
|
.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 { 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); }
|
.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 .menu-danger { color: var(--danger); }
|
||||||
.menu-ask { font-size: 13px; color: var(--ink-3); padding: 4px 8px; }
|
.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-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; }
|
.rail-card { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
|
||||||
.expert-card { align-items: center; }
|
.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-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-video { background: black; border-radius: 0; }
|
||||||
.minnect-audio { width: 100%; height: 36px; }
|
.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. */
|
/* Category chips (Find experts panel). 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%; }
|
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
.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 { 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; }
|
.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); }
|
.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> = {}) =>
|
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
|
||||||
renderToStaticMarkup(createElement(ExpertSearchView, {
|
renderToStaticMarkup(createElement(ExpertSearchView, {
|
||||||
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
|
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", () => {
|
describe("ExpertSearchView", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user