ai1ywm5xi: Add the Discover sheet with featured, trending, find experts panels and the appearance switch
This commit is contained in:
@@ -49,7 +49,7 @@ function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: ()
|
||||
<AppShell
|
||||
person={person}
|
||||
search={<ExpertSearch />}
|
||||
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} />}
|
||||
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} trendingMinnects={discover?.trendingMinnects} searchPanel={<ExpertSearch />} />}
|
||||
discover={<DiscoverRailsView data={discover} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -96,11 +96,11 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
||||
<main className="bento">
|
||||
<div className="bento-col bento-left">
|
||||
<AccountPanel person={person} />
|
||||
{search}
|
||||
{compact ? null : search}
|
||||
</div>
|
||||
<div className="bento-col bento-center">{feed}</div>
|
||||
<aside className="bento-col bento-right" aria-label="Discover">
|
||||
{discover}
|
||||
{compact ? null : discover}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
icon?: ReactNode;
|
||||
/** Raw leading content in place of the icon tile, for example a stack of avatars. */
|
||||
leading?: ReactNode;
|
||||
/** Trailing content, for example a chevron or the current theme name. */
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
|
||||
export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) {
|
||||
return (
|
||||
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
||||
{icon ? (
|
||||
{leading ?? null}
|
||||
{!leading && icon ? (
|
||||
<span className="btn-depth-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
// Mobile Discover sheet (spec "Sheets"): three soft depth rows that open the
|
||||
// Featured experts, Trending minnects and Find experts panels as 80% height
|
||||
// sheets, then the Dark/Light appearance switch. Styles: src/styles/sheet.css.
|
||||
import type { ReactNode } from "react";
|
||||
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import { useTheme } from "@/lib/use-theme";
|
||||
import type { Theme } from "@/lib/theme";
|
||||
import { BottomSheet } from "./BottomSheet";
|
||||
import { DepthButton } from "./Buttons";
|
||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||
|
||||
export type DiscoverPanel = "featured" | "trending" | "search";
|
||||
|
||||
/** Width of the center photo in the featured panel sheet. */
|
||||
export const PANEL_CAROUSEL_SIZE = 150;
|
||||
|
||||
/** "Name1, Name2 and N more" for the Featured experts row; shorter lists read plainly. */
|
||||
export function featuredSubtitle(experts: Pick<DiscoverExpert, "displayName">[]): string {
|
||||
const names = experts.map((e) => e.displayName || "Expert");
|
||||
if (names.length === 0) return "";
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
|
||||
}
|
||||
|
||||
function Chevron() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="btn-depth-chev">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||
|
||||
function FlameIcon() {
|
||||
return (
|
||||
<svg {...icon}>
|
||||
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg {...icon}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg {...icon} aria-hidden="true">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg {...icon} aria-hidden="true">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Dark/Light switch in the soft depth style; the choice persists through useTheme. */
|
||||
export function AppearanceSwitch() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const options: { value: Theme; label: string; icon: ReactNode }[] = [
|
||||
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
|
||||
{ value: "light", label: "Light", icon: <SunIcon /> },
|
||||
];
|
||||
return (
|
||||
<div className="seg-depth" role="group" aria-label="Appearance">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} type="button" className={theme === o.value ? "on" : undefined} aria-pressed={theme === o.value} onClick={() => setTheme(o.value)}>
|
||||
{o.icon}
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DiscoverSheetProps {
|
||||
experts: DiscoverExpert[];
|
||||
onOpen: (panel: DiscoverPanel) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
|
||||
return (
|
||||
<BottomSheet title="Discover" onClose={onClose} className="sheet-discover">
|
||||
{experts.length > 0 ? (
|
||||
<DepthButton
|
||||
label="Featured experts"
|
||||
sublabel={featuredSubtitle(experts)}
|
||||
onClick={() => onOpen("featured")}
|
||||
leading={
|
||||
<span className="depth-stack" aria-hidden="true">
|
||||
{experts.slice(0, 3).map((e) => (
|
||||
<HexAvatar key={String(e.id)} name={e.displayName || "Expert"} pictureUrl={e.pictureUrl} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
}
|
||||
trailing={<Chevron />}
|
||||
/>
|
||||
) : null}
|
||||
<DepthButton label="Trending minnects" sublabel="Answers people are watching" icon={<FlameIcon />} trailing={<Chevron />} onClick={() => onOpen("trending")} />
|
||||
<DepthButton label="Find experts" sublabel="Search by name, topic or category" icon={<SearchIcon />} trailing={<Chevron />} onClick={() => onOpen("search")} />
|
||||
<span className="type-eyebrow sheet-sub">Appearance</span>
|
||||
<AppearanceSwitch />
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DiscoverPanelSheetProps {
|
||||
panel: DiscoverPanel;
|
||||
experts: DiscoverExpert[];
|
||||
trending: DiscoverMinnect[];
|
||||
search?: ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TITLES: Record<DiscoverPanel, string> = {
|
||||
featured: "Featured experts",
|
||||
trending: "Trending minnects",
|
||||
search: "Find experts",
|
||||
};
|
||||
|
||||
/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */
|
||||
export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) {
|
||||
return (
|
||||
<BottomSheet title={TITLES[panel]} onClose={onClose} className={`sheet-panel sheet-panel-${panel}`}>
|
||||
{panel === "featured" ? <FeaturedCarousel experts={experts} size={PANEL_CAROUSEL_SIZE} variant="slide" /> : null}
|
||||
{panel === "trending" ? <TrendingMinnectsRail minnects={trending} /> : null}
|
||||
{panel === "search" ? search : null}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
||||
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
import type { ReactNode } from "react";
|
||||
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import type { ComposerProps } from "./Composer";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
import { useCompactLayout } from "./layout-context";
|
||||
@@ -18,6 +19,8 @@ export function Feed({
|
||||
feedUserId,
|
||||
person,
|
||||
featuredExperts,
|
||||
trendingMinnects,
|
||||
searchPanel,
|
||||
}: {
|
||||
onSignedOut: () => void;
|
||||
feedUserId?: string | null;
|
||||
@@ -25,6 +28,9 @@ export function Feed({
|
||||
person?: ComposerProps["person"];
|
||||
/** Shown as a slide after the second post on phones. */
|
||||
featuredExperts?: DiscoverExpert[];
|
||||
/** Phone Discover sheet: trending minnects and the Find experts panel. */
|
||||
trendingMinnects?: DiscoverMinnect[];
|
||||
searchPanel?: ReactNode;
|
||||
}) {
|
||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||
@@ -285,6 +291,8 @@ export function Feed({
|
||||
onSignedOut={onSignedOut}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||
featuredExperts={featuredExperts}
|
||||
trendingMinnects={trendingMinnects}
|
||||
searchPanel={searchPanel}
|
||||
hasMore={!!nextCursor}
|
||||
sentinelRef={sentinel}
|
||||
listRef={list}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import { Composer, type ComposerProps } from "./Composer";
|
||||
import { GrowButton, IconButton } from "./Buttons";
|
||||
import { PostCard } from "./PostCard";
|
||||
@@ -42,6 +42,10 @@ export interface FeedViewProps {
|
||||
hasMore?: boolean;
|
||||
/** Phone layout only: experts for the featured slide after the second post. */
|
||||
featuredExperts?: DiscoverExpert[];
|
||||
/** Phone layout only: trending minnects for the Discover sheet. */
|
||||
trendingMinnects?: DiscoverMinnect[];
|
||||
/** Phone layout only: the Find experts panel shown in the Discover sheet. */
|
||||
searchPanel?: ReactNode;
|
||||
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||
/** Phone layout only: the dock's comments button, for the post on screen. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MobileDock } from "./MobileDock";
|
||||
import { BottomSheet } from "./BottomSheet";
|
||||
import { CommentsSection } from "./CommentsSection";
|
||||
import { Composer } from "./Composer";
|
||||
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
import type { FeedViewProps } from "./FeedView";
|
||||
|
||||
@@ -281,8 +282,8 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
|
||||
export function MobileFeed(p: FeedViewProps) {
|
||||
const reel = useRef<HTMLDivElement | null>(null);
|
||||
const [current, setCurrent] = useState(0);
|
||||
// The open sheet: the comments of one post, the composer, or none.
|
||||
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | null>(null);
|
||||
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
|
||||
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
|
||||
const closeSheet = useCallback(() => setSheet(null), []);
|
||||
// A successful post closes the composer: publishing ends with no error and an emptied draft.
|
||||
const wasBusy = useRef(false);
|
||||
@@ -443,7 +444,10 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
setSheet({ kind: "composer" });
|
||||
p.onOpenComposer?.();
|
||||
}}
|
||||
onOpenMore={p.onOpenMore}
|
||||
onOpenMore={() => {
|
||||
setSheet({ kind: "more" });
|
||||
p.onOpenMore?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{sheet?.kind === "comments" ? (
|
||||
@@ -456,6 +460,12 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
<Composer {...p.composer} />
|
||||
</BottomSheet>
|
||||
) : null}
|
||||
{sheet?.kind === "more" ? (
|
||||
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
|
||||
) : null}
|
||||
{sheet?.kind === "panel" ? (
|
||||
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user