Promote to main: Feed browser luxury redesign #7
+10
-4
@@ -5,7 +5,7 @@ import { loadSession, type Person } from "@/lib/auth-client";
|
|||||||
import { SignInForm } from "./SignInForm";
|
import { SignInForm } from "./SignInForm";
|
||||||
import { AppShell } from "./AppShell";
|
import { AppShell } from "./AppShell";
|
||||||
import { Feed } from "./Feed";
|
import { Feed } from "./Feed";
|
||||||
import { DiscoverRails } from "./DiscoverRails";
|
import { DiscoverRailsView, useDiscover } from "./DiscoverRails";
|
||||||
import { ExpertSearch } from "./ExpertSearch";
|
import { ExpertSearch } from "./ExpertSearch";
|
||||||
|
|
||||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||||
@@ -39,12 +39,18 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}} />;
|
}} />;
|
||||||
}
|
}
|
||||||
|
return <SignedInHome person={state.person} onSignedOut={() => setState({ kind: "out" })} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signed-in shell. Discover loads once and feeds both the right rail and the phone feed. */
|
||||||
|
function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () => void }) {
|
||||||
|
const discover = useDiscover();
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
person={state.person}
|
person={person}
|
||||||
search={<ExpertSearch />}
|
search={<ExpertSearch />}
|
||||||
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
|
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} />}
|
||||||
discover={<DiscoverRails />}
|
discover={<DiscoverRailsView data={discover} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
|
/** Loads /api/discover once. Failure leaves it null; the feed is unaffected. */
|
||||||
export function DiscoverRails() {
|
export function useDiscover(): DiscoverData | null {
|
||||||
const [data, setData] = useState<DiscoverData | null>(null);
|
const [data, setData] = useState<DiscoverData | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
|
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
|
||||||
return () => { live = false; };
|
return () => { live = false; };
|
||||||
}, []);
|
}, []);
|
||||||
return <DiscoverRailsView data={data} />;
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoverRails() {
|
||||||
|
return <DiscoverRailsView data={useDiscover()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ export interface FeaturedCarouselProps {
|
|||||||
experts: DiscoverExpert[];
|
experts: DiscoverExpert[];
|
||||||
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** "slide": no tile or heading, for the phone featured slide that brings its own title. */
|
||||||
|
variant?: "panel" | "slide";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
|
export function FeaturedCarousel({ experts, size = 96, variant = "panel" }: FeaturedCarouselProps) {
|
||||||
const count = experts.length;
|
const count = experts.length;
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
@@ -79,23 +81,27 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
|
|||||||
const current = index % count;
|
const current = index % count;
|
||||||
const cap = experts[current];
|
const cap = experts[current];
|
||||||
const style = { "--cfw": `${size}px` } as CSSProperties;
|
const style = { "--cfw": `${size}px` } as CSSProperties;
|
||||||
|
const bare = variant === "slide";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="tile panel rail rail-featured featured"
|
className={bare ? "featured featured-bare" : "tile panel rail rail-featured featured"}
|
||||||
aria-labelledby="featured-title"
|
aria-labelledby={bare ? undefined : "featured-title"}
|
||||||
|
aria-label={bare ? "Featured experts" : undefined}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
|
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
|
||||||
data-paused={paused ? "true" : undefined}
|
data-paused={paused ? "true" : undefined}
|
||||||
>
|
>
|
||||||
|
{bare ? null : (
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||||
<Star />Featured experts
|
<Star />Featured experts
|
||||||
</h2>
|
</h2>
|
||||||
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
|
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
|
||||||
{experts.map((e, i) => {
|
{experts.map((e, i) => {
|
||||||
const slot = featuredSlot(i, current, count);
|
const slot = featuredSlot(i, current, count);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
|||||||
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||||
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import type { ComposerProps } from "./Composer";
|
import type { ComposerProps } from "./Composer";
|
||||||
import { FeedView, type FeedStatus } from "./FeedView";
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
import { useCompactLayout } from "./layout-context";
|
import { useCompactLayout } from "./layout-context";
|
||||||
@@ -16,11 +17,14 @@ export function Feed({
|
|||||||
onSignedOut,
|
onSignedOut,
|
||||||
feedUserId,
|
feedUserId,
|
||||||
person,
|
person,
|
||||||
|
featuredExperts,
|
||||||
}: {
|
}: {
|
||||||
onSignedOut: () => void;
|
onSignedOut: () => void;
|
||||||
feedUserId?: string | null;
|
feedUserId?: string | null;
|
||||||
/** The signed-in person, shown beside the composer. */
|
/** The signed-in person, shown beside the composer. */
|
||||||
person?: ComposerProps["person"];
|
person?: ComposerProps["person"];
|
||||||
|
/** Shown as a slide after the second post on phones. */
|
||||||
|
featuredExperts?: DiscoverExpert[];
|
||||||
}) {
|
}) {
|
||||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
@@ -280,6 +284,7 @@ export function Feed({
|
|||||||
deleteErrors={deleteErrors}
|
deleteErrors={deleteErrors}
|
||||||
onSignedOut={onSignedOut}
|
onSignedOut={onSignedOut}
|
||||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||||
|
featuredExperts={featuredExperts}
|
||||||
hasMore={!!nextCursor}
|
hasMore={!!nextCursor}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
listRef={list}
|
listRef={list}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode, 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 type { DiscoverExpert } from "@/lib/discover";
|
||||||
import { Composer, type ComposerProps } from "./Composer";
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
import { GrowButton, IconButton } from "./Buttons";
|
import { GrowButton, IconButton } from "./Buttons";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
@@ -40,6 +41,8 @@ export interface FeedViewProps {
|
|||||||
/** Another page can load; the phone feed ends with a loading slide. */
|
/** Another page can load; the phone feed ends with a loading slide. */
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
/** Phone layout only: the slide on screen changed (for the dock). */
|
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||||
|
/** Phone layout only: experts for the featured slide after the second post. */
|
||||||
|
featuredExperts?: DiscoverExpert[];
|
||||||
onCurrentChange?: (slide: CurrentSlide) => void;
|
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type Ref } from "react";
|
import { useEffect, useRef, useState, type MouseEvent, type Ref } from "react";
|
||||||
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||||
import { GrowButton } from "./Buttons";
|
import { GrowButton } from "./Buttons";
|
||||||
import { HexAvatar } from "./HexAvatar";
|
import { HexAvatar } from "./HexAvatar";
|
||||||
import { PlayIcon, PostMenu } from "./PostCard";
|
import { PlayIcon, PostMenu } from "./PostCard";
|
||||||
import { VideoPlayer } from "./VideoPlayer";
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import type { FeedViewProps } from "./FeedView";
|
import type { FeedViewProps } from "./FeedView";
|
||||||
|
|
||||||
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
|
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
|
||||||
export interface CurrentSlide {
|
export interface CurrentSlide {
|
||||||
index: number;
|
index: number;
|
||||||
|
/** "post" is a real post; "featured" and "load" are not posts (the dock disables like and comment on them). */
|
||||||
|
kind: "post" | "featured" | "load";
|
||||||
post: CardPost | null;
|
post: CardPost | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The featured experts slide comes after this many posts. */
|
||||||
|
export const FEATURED_AFTER = 2;
|
||||||
|
/** Two taps this close together (ms) make a double-tap. */
|
||||||
|
export const DOUBLE_TAP_MS = 300;
|
||||||
|
/** How long the heart burst stays on screen (ms). */
|
||||||
|
export const BURST_MS = 950;
|
||||||
|
|
||||||
|
/** True when a tap at `now` follows a tap at `last` closely enough to be a double-tap. */
|
||||||
|
export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_TAP_MS): boolean {
|
||||||
|
return last !== null && now - last >= 0 && now - last <= windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taps on controls (buttons, links, video, menus, fields) never count toward a double-tap. */
|
||||||
|
export function isTapTarget(target: EventTarget | null): boolean {
|
||||||
|
const el = target as Element | null;
|
||||||
|
if (!el || typeof el.closest !== "function") return true;
|
||||||
|
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
|
||||||
|
export function featuredSlideIndex(postCount: number, expertCount: number): number {
|
||||||
|
return expertCount > 0 && postCount >= FEATURED_AFTER ? FEATURED_AFTER : -1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Share of a slide that must be on screen before it counts as the current one. */
|
/** Share of a slide that must be on screen before it counts as the current one. */
|
||||||
const CURRENT_RATIO = 0.6;
|
const CURRENT_RATIO = 0.6;
|
||||||
|
|
||||||
@@ -122,11 +150,29 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
|||||||
// While a video plays, the author and text step aside so the native controls stay clear.
|
// While a video plays, the author and text step aside so the native controls stay clear.
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
|
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
|
||||||
|
const lastTap = useRef<number | null>(null);
|
||||||
|
const [burst, setBurst] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burst) return;
|
||||||
|
const t = window.setTimeout(() => setBurst(0), BURST_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [burst]);
|
||||||
|
// Double-tap likes (never unlikes) and plays the burst. A tap that scrolls or swipes never reaches click.
|
||||||
|
const onTap = (e: MouseEvent<HTMLElement>) => {
|
||||||
|
if (!isTapTarget(e.target)) { lastTap.current = null; return; }
|
||||||
|
const now = performance.now();
|
||||||
|
if (!isDoubleTap(lastTap.current, now)) { lastTap.current = now; return; }
|
||||||
|
lastTap.current = null;
|
||||||
|
if (!post.isLiked && post.id && p.onToggleLike && !p.likeBusy?.has(post.key)) p.onToggleLike(post);
|
||||||
|
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setBurst((n) => n + 1);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cls}
|
className={cls}
|
||||||
data-index={index}
|
data-index={index}
|
||||||
data-testid="feed-slide"
|
data-testid="feed-slide"
|
||||||
|
data-liked={post.isLiked ? "true" : "false"}
|
||||||
|
onClick={onTap}
|
||||||
aria-label={`Post by ${post.authorName}`}
|
aria-label={`Post by ${post.authorName}`}
|
||||||
onPlayCapture={() => setPlaying(true)}
|
onPlayCapture={() => setPlaying(true)}
|
||||||
onPauseCapture={() => setPlaying(false)}
|
onPauseCapture={() => setPlaying(false)}
|
||||||
@@ -143,6 +189,11 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
|||||||
<p className="slide-quote">{post.text}</p>
|
<p className="slide-quote">{post.text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{burst ? (
|
||||||
|
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
{own ? (
|
{own ? (
|
||||||
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -164,6 +215,37 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChevronDown() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not a post: the dock disables like and comment here (data-slide-kind="featured"). */
|
||||||
|
function FeaturedSlide({ index, current, experts }: { index: number; current: boolean; experts: DiscoverExpert[] }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`slide slide-featured${current ? " is-cur" : ""}`}
|
||||||
|
data-index={index}
|
||||||
|
data-slide-kind="featured"
|
||||||
|
data-testid="feed-slide-featured"
|
||||||
|
aria-label="Featured experts"
|
||||||
|
>
|
||||||
|
<span className="type-eyebrow slide-feat-eyebrow">Featured experts</span>
|
||||||
|
<h2 className="type-title type-title-strong slide-feat-title">Ask the people everyone is talking about</h2>
|
||||||
|
<div className="slide-feat-cf">
|
||||||
|
<FeaturedCarousel experts={experts} size={156} variant="slide" />
|
||||||
|
</div>
|
||||||
|
<span className="slide-hint">
|
||||||
|
<ChevronDown />
|
||||||
|
Keep scrolling
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
|
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
|
||||||
return (
|
return (
|
||||||
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
|
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
|
||||||
@@ -197,7 +279,13 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const onCurrentChange = p.onCurrentChange;
|
const onCurrentChange = p.onCurrentChange;
|
||||||
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
|
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
|
||||||
const slideCount = (p.status === "ready" ? p.posts.length : 0) + (showLoad ? 1 : 0);
|
const experts = p.featuredExperts ?? [];
|
||||||
|
const postCount = p.status === "ready" ? p.posts.length : 0;
|
||||||
|
const featuredAt = p.status === "ready" ? featuredSlideIndex(postCount, experts.length) : -1;
|
||||||
|
const slideCount = postCount + (featuredAt >= 0 ? 1 : 0) + (showLoad ? 1 : 0);
|
||||||
|
/** Slide index of post `i`: posts after the featured slide shift down by one. */
|
||||||
|
const slideOf = (i: number) => (featuredAt >= 0 && i >= featuredAt ? i + 1 : i);
|
||||||
|
const postAt = (slide: number) => (featuredAt >= 0 && slide === featuredAt ? -1 : featuredAt >= 0 && slide > featuredAt ? slide - 1 : slide);
|
||||||
|
|
||||||
// Track the slide on screen. A fresh observer per change picks up new slides.
|
// Track the slide on screen. A fresh observer per change picks up new slides.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,11 +351,13 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
|
|
||||||
// Keep the index inside the list when posts are removed.
|
// Keep the index inside the list when posts are removed.
|
||||||
const index = Math.min(current, Math.max(0, slideCount - 1));
|
const index = Math.min(current, Math.max(0, slideCount - 1));
|
||||||
const currentPost = p.status === "ready" ? p.posts[index] ?? null : null;
|
const currentPostIndex = postAt(index);
|
||||||
|
const currentPost = p.status === "ready" && currentPostIndex >= 0 ? p.posts[currentPostIndex] ?? null : null;
|
||||||
|
const kind: CurrentSlide["kind"] = currentPost ? "post" : featuredAt >= 0 && index === featuredAt ? "featured" : "load";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCurrentChange?.({ index, post: currentPost });
|
onCurrentChange?.({ index, kind, post: currentPost });
|
||||||
}, [index, currentPost, onCurrentChange]);
|
}, [index, kind, currentPost, onCurrentChange]);
|
||||||
|
|
||||||
// A video keeps playing only while its slide is on screen.
|
// A video keeps playing only while its slide is on screen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -304,18 +394,23 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{p.status === "ready"
|
{p.status === "ready"
|
||||||
? p.posts.map((post, i) => <PostSlide key={post.key} post={post} index={i} current={i === index} p={p} />)
|
? p.posts.flatMap((post, i) => {
|
||||||
|
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} />;
|
||||||
|
return i === featuredAt - 1
|
||||||
|
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
|
||||||
|
: [node];
|
||||||
|
})
|
||||||
: null}
|
: null}
|
||||||
{p.status === "ready" && p.loadMoreFailed ? (
|
{p.status === "ready" && p.loadMoreFailed ? (
|
||||||
<section className="slide slide-message" data-index={p.posts.length} role="alert">
|
<section className="slide slide-message" data-index={slideCount} 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>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{showLoad ? (
|
{showLoad ? (
|
||||||
<LoadSlide
|
<LoadSlide
|
||||||
index={p.status === "ready" ? p.posts.length : 0}
|
index={slideCount - 1}
|
||||||
current={index === (p.status === "ready" ? p.posts.length : 0)}
|
current={index === slideCount - 1}
|
||||||
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
|
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export function FeedPreview() {
|
|||||||
status="ready"
|
status="ready"
|
||||||
loadingMore={loadingMore || paging}
|
loadingMore={loadingMore || paging}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
listRef={listRef}
|
listRef={listRef}
|
||||||
loadMoreFailed={false}
|
loadMoreFailed={false}
|
||||||
|
|||||||
@@ -203,6 +203,59 @@
|
|||||||
}
|
}
|
||||||
.slide-menu .post-pop { min-width: 230px; }
|
.slide-menu .post-pop { min-width: 230px; }
|
||||||
|
|
||||||
|
/* Double-tap heart burst over a post slide (mockup .burst), crimson accent. */
|
||||||
|
@keyframes slide-burst {
|
||||||
|
0% { opacity: 0; transform: scale(.2); }
|
||||||
|
25% { opacity: 1; transform: scale(1.15); }
|
||||||
|
60% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.25) translateY(-20px); }
|
||||||
|
}
|
||||||
|
.slide-burst {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 44%;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: -60px 0 0 -60px;
|
||||||
|
color: var(--accent);
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: slide-burst .95s cubic-bezier(.2, .9, .3, 1.3) both;
|
||||||
|
filter: drop-shadow(0 12px 30px color-mix(in oklab, var(--accent), transparent 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Featured experts slide: not a post. Eyebrow, title, carousel at 156px, hint. */
|
||||||
|
@keyframes slide-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
|
||||||
|
.slide-featured {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 70px 0 110px;
|
||||||
|
background: radial-gradient(90% 55% at 50% 46%, color-mix(in oklab, var(--accent), transparent 84%), transparent 70%), var(--bg);
|
||||||
|
}
|
||||||
|
.slide-feat-title {
|
||||||
|
margin: 10px 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.slide-feat-cf { width: 100%; margin-top: 26px; }
|
||||||
|
.featured-bare { background: none; }
|
||||||
|
.featured-bare .featured-cap { padding-top: 16px; }
|
||||||
|
.slide-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.slide-hint svg { width: 16px; height: 16px; animation: slide-nudge 1.6s ease-in-out infinite; }
|
||||||
|
|
||||||
/* Loading slide: a skeleton card in the middle while the next page loads. */
|
/* Loading slide: a skeleton card in the middle while the next page loads. */
|
||||||
.slide-load { background: var(--bg); }
|
.slide-load { background: var(--bg); }
|
||||||
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
|
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
|
||||||
@@ -271,6 +324,8 @@
|
|||||||
.is-cur .slide-meta,
|
.is-cur .slide-meta,
|
||||||
.is-cur .slide-quote-w,
|
.is-cur .slide-quote-w,
|
||||||
.slide-skel i,
|
.slide-skel i,
|
||||||
|
.slide-burst,
|
||||||
|
.slide-hint svg,
|
||||||
.reel-pill { animation: none !important; }
|
.reel-pill { animation: none !important; }
|
||||||
.slide-art { transition: none !important; }
|
.slide-art { transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { createElement } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
import { pickCurrent } from "@/components/MobileFeed";
|
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex } from "@/components/MobileFeed";
|
||||||
|
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
|
||||||
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
||||||
import { toCardPosts } from "@/lib/feed-model";
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
|
|
||||||
@@ -129,3 +130,44 @@ describe("video slide before play", () => {
|
|||||||
expect(html).toContain('class="post-play slide-play"');
|
expect(html).toContain('class="post-play slide-play"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("double-tap detection", () => {
|
||||||
|
it("needs two taps within 300ms", () => {
|
||||||
|
expect(isDoubleTap(null, 100)).toBe(false);
|
||||||
|
expect(isDoubleTap(100, 250)).toBe(true);
|
||||||
|
expect(isDoubleTap(100, 400)).toBe(true);
|
||||||
|
expect(isDoubleTap(100, 401)).toBe(false);
|
||||||
|
});
|
||||||
|
it("ignores taps on controls", () => {
|
||||||
|
const el = (html: string) => {
|
||||||
|
const fake = (match: boolean) => ({ closest: () => (match ? {} : null) });
|
||||||
|
return html === "slide" ? fake(false) : fake(true);
|
||||||
|
};
|
||||||
|
expect(isTapTarget(el("slide") as unknown as EventTarget)).toBe(true);
|
||||||
|
expect(isTapTarget(el("button") as unknown as EventTarget)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("featured experts slide", () => {
|
||||||
|
it("sits after the second post and only with experts", () => {
|
||||||
|
expect(featuredSlideIndex(3, 5)).toBe(2);
|
||||||
|
expect(featuredSlideIndex(2, 5)).toBe(2);
|
||||||
|
expect(featuredSlideIndex(1, 5)).toBe(-1);
|
||||||
|
expect(featuredSlideIndex(3, 0)).toBe(-1);
|
||||||
|
});
|
||||||
|
it("renders the title, eyebrow, hint and a 156px carousel as a non-post slide", () => {
|
||||||
|
const out = render({ featuredExperts: FIXTURE_DISCOVER.featuredExperts });
|
||||||
|
expect(out).toContain('data-slide-kind="featured"');
|
||||||
|
expect(out).toContain("Featured experts");
|
||||||
|
expect(out).toContain("Ask the people everyone is talking about");
|
||||||
|
expect(out).toContain("--cfw:156px");
|
||||||
|
expect(out).toContain("Keep scrolling");
|
||||||
|
expect(out.match(/data-testid="feed-slide"/g)).toHaveLength(3);
|
||||||
|
// After the second post, before the third.
|
||||||
|
expect(out.indexOf("Author i1")).toBeLessThan(out.indexOf("feed-slide-featured"));
|
||||||
|
expect(out.indexOf("feed-slide-featured")).toBeLessThan(out.indexOf("Author v1"));
|
||||||
|
});
|
||||||
|
it("is absent without experts", () => {
|
||||||
|
expect(render({})).not.toContain("feed-slide-featured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user