Promote to main: Feed browser luxury redesign #7
@@ -62,6 +62,15 @@
|
||||
--accent-glow-0: rgba(255,110,110,0);
|
||||
/* Text and marks on a crimson face: white in both themes. */
|
||||
--on-accent: #FFFFFF;
|
||||
/* Over photos and video (mobile slides): white text, dark fades and dark glass, same in both themes. */
|
||||
--on-media: #FFFFFF;
|
||||
--on-media-line: rgba(255,255,255,.18);
|
||||
--on-media-badge: rgba(255,255,255,.5);
|
||||
--media-fade: rgba(0,0,0,.8);
|
||||
--media-fade-top: rgba(0,0,0,.45);
|
||||
--media-fade-0: rgba(0,0,0,0);
|
||||
--media-glass: rgba(0,0,0,.28);
|
||||
--media-shadow: rgba(0,0,0,.35);
|
||||
|
||||
/* HexAvatar initials: dark muted gradients, same in both themes. */
|
||||
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
|
||||
|
||||
@@ -14,6 +14,7 @@ import "../styles/featured.css";
|
||||
import "../styles/feed.css";
|
||||
import "../styles/trending.css";
|
||||
import "../styles/search.css";
|
||||
import "../styles/mobile.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, 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";
|
||||
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
|
||||
|
||||
const iconProps = {
|
||||
viewBox: "0 0 24 24",
|
||||
@@ -73,8 +74,25 @@ export interface AppShellProps {
|
||||
|
||||
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
|
||||
export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
|
||||
// The CSS switches layout with a container query on .app; the feed also
|
||||
// needs to know, because the phone feed is different markup (full-screen slides).
|
||||
useLayoutEffect(() => {
|
||||
const el = root.current;
|
||||
if (!el) return;
|
||||
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<CompactLayoutContext.Provider value={compact}>
|
||||
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
|
||||
<main className="bento">
|
||||
<div className="bento-col bento-left">
|
||||
<AccountPanel person={person} />
|
||||
@@ -86,5 +104,6 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</CompactLayoutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||
import type { ComposerProps } from "./Composer";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
import { useCompactLayout } from "./layout-context";
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
|
||||
@@ -44,6 +45,9 @@ export function Feed({
|
||||
const list = useRef<HTMLDivElement | null>(null);
|
||||
const busy = useRef(false);
|
||||
const shown = useRef<Set<string>>(new Set());
|
||||
// The list element changes between the panel and the phone reel; the
|
||||
// infinite-scroll observer re-attaches to the new one.
|
||||
const compact = useCompactLayout();
|
||||
|
||||
useEffect(() => {
|
||||
shown.current = new Set(posts.map((p) => p.key));
|
||||
@@ -117,7 +121,7 @@ export function Feed({
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
|
||||
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
|
||||
|
||||
// Real-time: poll the first page while the tab is visible.
|
||||
useEffect(() => {
|
||||
@@ -276,6 +280,7 @@ export function Feed({
|
||||
deleteErrors={deleteErrors}
|
||||
onSignedOut={onSignedOut}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||
hasMore={!!nextCursor}
|
||||
sentinelRef={sentinel}
|
||||
listRef={list}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||
import { Composer, type ComposerProps } from "./Composer";
|
||||
import { GrowButton, IconButton } from "./Buttons";
|
||||
import { PostCard } from "./PostCard";
|
||||
import { useCompactLayout } from "./layout-context";
|
||||
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
|
||||
|
||||
export type FeedStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -33,6 +37,10 @@ export interface FeedViewProps {
|
||||
/** The scrolling feed list; the page itself never scrolls. */
|
||||
listRef?: Ref<HTMLDivElement>;
|
||||
now?: number;
|
||||
/** Another page can load; the phone feed ends with a loading slide. */
|
||||
hasMore?: boolean;
|
||||
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
@@ -104,6 +112,9 @@ function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { chi
|
||||
}
|
||||
|
||||
export function FeedView(p: FeedViewProps) {
|
||||
// At 760px and below the feed is the full-screen snap reel instead of the panel.
|
||||
const compact = useCompactLayout();
|
||||
if (compact) return <MobileFeed {...p} />;
|
||||
if (p.status === "loading") {
|
||||
return (
|
||||
<FeedPanel onRefresh={p.onRefresh} busy>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type Ref } from "react";
|
||||
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||
import { GrowButton } from "./Buttons";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { PlayIcon, PostMenu } from "./PostCard";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import type { FeedViewProps } from "./FeedView";
|
||||
|
||||
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
|
||||
export interface CurrentSlide {
|
||||
index: number;
|
||||
post: CardPost | null;
|
||||
}
|
||||
|
||||
/** Share of a slide that must be on screen before it counts as the current one. */
|
||||
const CURRENT_RATIO = 0.6;
|
||||
|
||||
/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */
|
||||
const SWIPE_MIN = 40;
|
||||
/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */
|
||||
const WHEEL_QUIET_MS = 220;
|
||||
/** A wheel gesture moves at most one slide in this time. */
|
||||
const WHEEL_STEP_MS = 520;
|
||||
|
||||
/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */
|
||||
export function stepTarget(from: number, delta: number, count: number): number {
|
||||
if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0));
|
||||
return Math.min(Math.max(from + Math.sign(delta), 0), count - 1);
|
||||
}
|
||||
|
||||
/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */
|
||||
export function pickCurrent(previous: number, visible: number[]): number {
|
||||
if (!visible.length) return previous;
|
||||
return visible.includes(previous) ? previous : Math.min(...visible);
|
||||
}
|
||||
|
||||
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === "function") ref(value);
|
||||
else if (ref) (ref as { current: T | null }).current = value;
|
||||
}
|
||||
|
||||
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const playable = isPlayableVideo(media);
|
||||
if (playable) {
|
||||
const duration = formatDuration(media.durationSeconds);
|
||||
const start = () => {
|
||||
setStarted(true);
|
||||
void box.current?.querySelector("video")?.play().catch(() => {});
|
||||
};
|
||||
return (
|
||||
<div className="slide-media slide-video" ref={box}>
|
||||
<div className="slide-art">
|
||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
|
||||
</div>
|
||||
{started ? null : (
|
||||
<>
|
||||
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (media.src) {
|
||||
return (
|
||||
<div className="slide-media">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="slide-art" src={media.src} alt={media.kind === "video" ? "Video thumbnail" : ""} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="slide-media slide-pending" role="img" aria-label="Video is processing">
|
||||
<span>Video is processing</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
|
||||
const media = post.media.find((m) => m.kind === "video" || m.src) ?? null;
|
||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}`;
|
||||
return (
|
||||
<section className={cls} data-index={index} data-testid="feed-slide" aria-label={`Post by ${post.authorName}`}>
|
||||
{media ? (
|
||||
<>
|
||||
<SlideMedia media={media} author={post.authorName} />
|
||||
<div className="slide-scrim-top" aria-hidden="true" />
|
||||
<div className="slide-scrim" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<div className="slide-quote-w">
|
||||
<p className="slide-quote">{post.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{own ? (
|
||||
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
||||
) : null}
|
||||
<div className="slide-meta">
|
||||
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
|
||||
<div className="slide-who">
|
||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||
<div>
|
||||
<strong>
|
||||
{post.authorName}
|
||||
{own ? <span className="post-you slide-you">You</span> : null}
|
||||
</strong>
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
|
||||
</div>
|
||||
</div>
|
||||
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
|
||||
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
<div className="slide-load-body" role="status" aria-label="Loading more posts">
|
||||
<div className="slide-skel">
|
||||
<div className="slide-skel-head">
|
||||
<i className="slide-skel-hex" />
|
||||
<div className="slide-skel-lines">
|
||||
<i style={{ width: "40%" }} />
|
||||
<i style={{ width: "24%" }} />
|
||||
</div>
|
||||
</div>
|
||||
<i style={{ width: "92%" }} />
|
||||
<i style={{ width: "76%" }} />
|
||||
<i style={{ width: "54%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen,
|
||||
* snapping. Uses the same posts, delete and paging as the desktop list: the
|
||||
* reel is the list ref (the infinite-scroll root) and the loading slide holds
|
||||
* the sentinel. Reports the slide on screen through onCurrentChange.
|
||||
*/
|
||||
export function MobileFeed(p: FeedViewProps) {
|
||||
const reel = useRef<HTMLDivElement | null>(null);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const onCurrentChange = p.onCurrentChange;
|
||||
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);
|
||||
|
||||
// Track the slide on screen. A fresh observer per change picks up new slides.
|
||||
useEffect(() => {
|
||||
const root = reel.current;
|
||||
if (!root || typeof IntersectionObserver === "undefined") return;
|
||||
const ratios = new Map<number, number>();
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
|
||||
const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i);
|
||||
setCurrent((prev) => pickCurrent(prev, visible));
|
||||
},
|
||||
{ root, threshold: [0, CURRENT_RATIO, 1] },
|
||||
);
|
||||
root.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => io.observe(el));
|
||||
return () => io.disconnect();
|
||||
}, [slideCount]);
|
||||
|
||||
// One wheel notch or one swipe moves exactly one slide. Native snapping alone
|
||||
// sends a short or slow gesture back to the slide it started on.
|
||||
useEffect(() => {
|
||||
const root = reel.current;
|
||||
if (!root) return;
|
||||
const count = () => root.querySelectorAll(":scope > .slide").length;
|
||||
const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1));
|
||||
const go = (to: number) => {
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" });
|
||||
};
|
||||
let lastWheel = 0;
|
||||
let lastStep = 0;
|
||||
let from = 0;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS;
|
||||
lastWheel = now;
|
||||
if (!fresh || !e.deltaY) return;
|
||||
lastStep = now;
|
||||
go(stepTarget(at(), e.deltaY, count()));
|
||||
};
|
||||
let startY: number | null = null;
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
|
||||
from = at();
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (startY === null || e.touches.length) return;
|
||||
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
|
||||
startY = null;
|
||||
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
|
||||
};
|
||||
root.addEventListener("wheel", onWheel, { passive: false });
|
||||
root.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
root.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
root.removeEventListener("wheel", onWheel);
|
||||
root.removeEventListener("touchstart", onTouchStart);
|
||||
root.removeEventListener("touchend", onTouchEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the index inside the list when posts are removed.
|
||||
const index = Math.min(current, Math.max(0, slideCount - 1));
|
||||
const currentPost = p.status === "ready" ? p.posts[index] ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
onCurrentChange?.({ index, post: currentPost });
|
||||
}, [index, currentPost, onCurrentChange]);
|
||||
|
||||
// A video keeps playing only while its slide is on screen.
|
||||
useEffect(() => {
|
||||
reel.current?.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => {
|
||||
if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
|
||||
});
|
||||
}, [index]);
|
||||
|
||||
return (
|
||||
<section className="reel-feed" aria-label="Home feed">
|
||||
{p.newCount > 0 && p.status === "ready" ? (
|
||||
<div className="reel-pill">
|
||||
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="reel"
|
||||
data-testid="feed-reel"
|
||||
data-current={index}
|
||||
ref={(el) => {
|
||||
reel.current = el;
|
||||
setRef(p.listRef, el);
|
||||
}}
|
||||
>
|
||||
{p.status === "error" ? (
|
||||
<section className="slide slide-message is-cur" data-index={0} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</section>
|
||||
) : null}
|
||||
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
|
||||
<section className="slide slide-message is-cur" data-index={0}>
|
||||
<p>No posts yet.</p>
|
||||
</section>
|
||||
) : null}
|
||||
{p.status === "ready"
|
||||
? p.posts.map((post, i) => <PostSlide key={post.key} post={post} index={i} current={i === index} p={p} />)
|
||||
: null}
|
||||
{p.status === "ready" && p.loadMoreFailed ? (
|
||||
<section className="slide slide-message" data-index={p.posts.length} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</section>
|
||||
) : null}
|
||||
{showLoad ? (
|
||||
<LoadSlide
|
||||
index={p.status === "ready" ? p.posts.length : 0}
|
||||
current={index === (p.status === "ready" ? p.posts.length : 0)}
|
||||
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+35
-19
@@ -59,7 +59,7 @@ function TrashIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
export function PlayIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
|
||||
@@ -91,13 +91,21 @@ function PostVideo({ media, author }: { media: CardMedia; author: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
export interface PostMenuProps {
|
||||
post: CardPost;
|
||||
onDelete?: (post: CardPost) => void;
|
||||
deleteBusy?: boolean;
|
||||
/** Extra class on the wrapper, e.g. the mobile slide's frosted position. */
|
||||
className?: string;
|
||||
/** Class for the "..." button; defaults to the card's button. */
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
/** The own-post "..." menu with the delete confirm. Shared by the card and the mobile slide. */
|
||||
export function PostMenu({ post, onDelete, deleteBusy, className, buttonClassName = "post-menu-btn" }: PostMenuProps) {
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [beat, setBeat] = useState(false);
|
||||
const menuBox = useRef<HTMLDivElement>(null);
|
||||
const own = !!(canDelete && onDelete);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
@@ -113,21 +121,10 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
||||
<header className="post-head">
|
||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||
<div className="post-who">
|
||||
<strong>
|
||||
{post.authorName}
|
||||
{own ? <span className="post-you">You</span> : null}
|
||||
</strong>
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||
</div>
|
||||
{own ? (
|
||||
<div className="post-menu" ref={menuBox}>
|
||||
<div className={className ? `post-menu ${className}` : "post-menu"} ref={menuBox}>
|
||||
<button
|
||||
type="button"
|
||||
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
|
||||
className={menu ? `${buttonClassName} ${buttonClassName}-open` : buttonClassName}
|
||||
aria-label="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu}
|
||||
@@ -169,7 +166,26 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [beat, setBeat] = useState(false);
|
||||
const own = !!(canDelete && onDelete);
|
||||
|
||||
return (
|
||||
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
||||
<header className="post-head">
|
||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||
<div className="post-who">
|
||||
<strong>
|
||||
{post.authorName}
|
||||
{own ? <span className="post-you">You</span> : null}
|
||||
</strong>
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||
</div>
|
||||
{own ? <PostMenu post={post} onDelete={onDelete} deleteBusy={deleteBusy} /> : null}
|
||||
</header>
|
||||
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
|
||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */
|
||||
export const COMPACT_MAX_WIDTH = 760;
|
||||
|
||||
/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */
|
||||
export function isCompactWidth(width: number): boolean {
|
||||
return width > 0 && width <= COMPACT_MAX_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the signed-in shell is in the phone layout. AppShell measures `.app`
|
||||
* and provides it; outside the shell (tests, other pages) it is false, so the
|
||||
* desktop markup renders.
|
||||
*/
|
||||
export const CompactLayoutContext = createContext(false);
|
||||
|
||||
export function useCompactLayout(): boolean {
|
||||
return useContext(CompactLayoutContext);
|
||||
}
|
||||
+37
-3
@@ -3,7 +3,7 @@
|
||||
// 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||
@@ -31,6 +31,38 @@ export function FeedPreview() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const list = useRef<HTMLDivElement | null>(null);
|
||||
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
|
||||
const [listEl, setListEl] = useState<HTMLDivElement | null>(null);
|
||||
const listRef = useCallback((el: HTMLDivElement | null) => {
|
||||
list.current = el;
|
||||
setListEl(el);
|
||||
}, []);
|
||||
// Paging: two more fixture pages load when the sentinel scrolls into view,
|
||||
// the same way Feed pages (the observer root is the list or the phone reel).
|
||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [paging, setPaging] = useState(false);
|
||||
const hasMore = page < 2;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el || !hasMore || paging) return;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return;
|
||||
setPaging(true);
|
||||
window.setTimeout(() => {
|
||||
const next = page + 1;
|
||||
const items = FIXTURE_FEED_ITEMS.map((raw) => {
|
||||
const it = raw as { post: { id: string } };
|
||||
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
|
||||
});
|
||||
setPosts((cur) => [...cur, ...toCardPosts(items)]);
|
||||
setPage(next);
|
||||
setPaging(false);
|
||||
}, 900);
|
||||
}, { root: listEl, rootMargin: "400px" });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasMore, paging, page, posts.length, listEl]);
|
||||
|
||||
// The comments calls answer from the fixtures; the preview never reaches the network.
|
||||
useEffect(() => {
|
||||
@@ -91,8 +123,10 @@ export function FeedPreview() {
|
||||
<FeedView
|
||||
posts={posts}
|
||||
status="ready"
|
||||
loadingMore={loadingMore}
|
||||
listRef={list}
|
||||
loadingMore={loadingMore || paging}
|
||||
hasMore={hasMore}
|
||||
sentinelRef={sentinel}
|
||||
listRef={listRef}
|
||||
loadMoreFailed={false}
|
||||
newCount={newCount}
|
||||
onRetry={() => {}}
|
||||
|
||||
@@ -120,10 +120,14 @@
|
||||
.feed .composer { margin: 0 14px 4px; }
|
||||
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
|
||||
|
||||
/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */
|
||||
/* 760px and below: the full-screen feed (spec "Mobile layout"). The feed
|
||||
column fills the app frame and the reel inside it is the only scroller.
|
||||
The side panels are hidden; the phone dock and sheets reach them later. */
|
||||
@container app (max-width: 760px) {
|
||||
.bento-col .panel { flex: 0 0 auto; }
|
||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
||||
.bento { display: block; padding: 0; overflow: hidden; }
|
||||
.bento-left,
|
||||
.bento-right { display: none; }
|
||||
.bento-center { height: 100%; }
|
||||
}
|
||||
|
||||
/* Account panel ---------------------------------------------------------- */
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Phone feed: full-screen snapping slides (spec "Mobile layout" > "Full-screen
|
||||
* feed", ADR 0001). Ported from the mockup's .reel / .slide / .sl-* rules with
|
||||
* the `slide-` and `reel-` prefixes, tokens only. White, the dark fades and the
|
||||
* dark glass over photos and video come from the --on-media / --media-* tokens.
|
||||
* Only rendered at 760px and below (AppShell measures .app).
|
||||
*/
|
||||
@keyframes slide-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes slide-shimmer { from { background-position: 120% 0; } to { background-position: -100% 0; } }
|
||||
|
||||
.reel-feed {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.reel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.reel::-webkit-scrollbar { display: none; }
|
||||
|
||||
.slide {
|
||||
--slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
overflow: hidden;
|
||||
background: var(--slide-text-bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Media: edge to edge. Scaled to 104% until the slide is on screen, then eases to 100%. */
|
||||
.slide-media { position: absolute; inset: 0; background: var(--tile-3); }
|
||||
.slide-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scale(1.04);
|
||||
transition: transform 6s ease-out;
|
||||
}
|
||||
.is-cur .slide-art { transform: scale(1); }
|
||||
.slide-video .slide-art video,
|
||||
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.slide-video .slide-art > div { height: 100%; }
|
||||
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
|
||||
.slide-play { z-index: 4; }
|
||||
.slide-dur { top: 128px; right: 16px; z-index: 4; }
|
||||
|
||||
.slide-scrim,
|
||||
.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; }
|
||||
.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
|
||||
.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); }
|
||||
|
||||
/* Author and text over the bottom fade. */
|
||||
.slide-meta {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 24px;
|
||||
bottom: 108px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.slide-meta > * { pointer-events: auto; }
|
||||
.slide-on-media .slide-meta { color: var(--on-media); }
|
||||
.slide-who { display: flex; align-items: center; gap: 12px; }
|
||||
.slide-who > div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.slide-who strong { display: flex; align-items: center; font-size: 15px; font-weight: 700; }
|
||||
.slide-who time { font-size: 12.5px; opacity: .72; }
|
||||
.slide-on-media .slide-you { color: var(--on-media); box-shadow: inset 0 0 0 1px var(--on-media-badge); }
|
||||
.slide-text {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 1px 12px var(--media-shadow);
|
||||
}
|
||||
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; }
|
||||
.slide-on-media .slide-error { color: var(--on-media); }
|
||||
|
||||
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark. */
|
||||
.slide-quote-w { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.slide-quote {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 28px;
|
||||
top: 46%;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
font-family: var(--font-serif), Georgia, serif;
|
||||
font-size: 31px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 8;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-quote::before {
|
||||
content: "\201C";
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent);
|
||||
font-size: 84px;
|
||||
line-height: .62;
|
||||
}
|
||||
|
||||
/* The slide on screen: its content rises in when it snaps into place. */
|
||||
.is-cur .slide-meta,
|
||||
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
|
||||
|
||||
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
|
||||
.slide-menu { position: absolute; top: 70px; right: 12px; z-index: 6; }
|
||||
.slide-menu-btn {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
color: var(--ink);
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.6);
|
||||
backdrop-filter: blur(16px) saturate(1.6);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
cursor: pointer;
|
||||
transition: transform .15s, background-color .2s;
|
||||
}
|
||||
.slide-menu-btn svg { width: 20px; height: 20px; }
|
||||
.slide-menu-btn:active { transform: scale(.92); }
|
||||
.slide-menu-btn:disabled { cursor: default; }
|
||||
.slide-on-media .slide-menu-btn {
|
||||
color: var(--on-media);
|
||||
background: var(--media-glass);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.slide-menu .post-pop { min-width: 230px; }
|
||||
|
||||
/* Loading slide: a skeleton card in the middle while the next page loads. */
|
||||
.slide-load { background: var(--bg); }
|
||||
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
|
||||
.slide-load-body {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 0 22px 110px;
|
||||
}
|
||||
.slide-skel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-card);
|
||||
}
|
||||
.slide-skel-head { display: flex; align-items: center; gap: 12px; }
|
||||
.slide-skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
||||
.slide-skel i {
|
||||
display: block;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--tile-2) 0%, var(--tile-3) 40%, var(--tile-2) 80%);
|
||||
background-size: 220% 100%;
|
||||
animation: slide-shimmer 1.4s linear infinite;
|
||||
}
|
||||
.slide-skel .slide-skel-hex {
|
||||
flex: none;
|
||||
width: 46px;
|
||||
height: 53px;
|
||||
border-radius: 0;
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
|
||||
/* Empty feed or a failed page, as a slide. */
|
||||
.slide-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 24px 110px;
|
||||
text-align: center;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* New-posts pill floats over the top of the reel. */
|
||||
.reel-pill {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: feed-pill-in .45s var(--ease) backwards;
|
||||
}
|
||||
.reel-pill > * { pointer-events: auto; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.is-cur .slide-meta,
|
||||
.is-cur .slide-quote-w,
|
||||
.slide-skel i,
|
||||
.reel-pill { animation: none !important; }
|
||||
.slide-art { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||
import { pickCurrent } from "@/components/MobileFeed";
|
||||
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
||||
import { toCardPosts } from "@/lib/feed-model";
|
||||
|
||||
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||
const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({
|
||||
type: "post",
|
||||
publishedAt: "2026-10-19T11:00:00Z",
|
||||
likesCount: 1,
|
||||
commentsCount: 0,
|
||||
post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems },
|
||||
});
|
||||
const POSTS = toCardPosts([
|
||||
item("t1", "me"),
|
||||
item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]),
|
||||
item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]),
|
||||
]);
|
||||
|
||||
function render(over: Partial<FeedViewProps>, compact = true) {
|
||||
const props: FeedViewProps = {
|
||||
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
|
||||
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over,
|
||||
};
|
||||
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
|
||||
}
|
||||
|
||||
describe("compact layout", () => {
|
||||
it("switches at 760px and below", () => {
|
||||
expect(isCompactWidth(390)).toBe(true);
|
||||
expect(isCompactWidth(760)).toBe(true);
|
||||
expect(isCompactWidth(761)).toBe(false);
|
||||
expect(isCompactWidth(1440)).toBe(false);
|
||||
expect(isCompactWidth(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the desktop panel outside the phone layout", () => {
|
||||
const html = render({}, false);
|
||||
expect(html).toContain('data-testid="post-card"');
|
||||
expect(html).not.toContain("feed-reel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile feed", () => {
|
||||
it("renders one slide per post, text as a quote and media under the fades", () => {
|
||||
const html = render({});
|
||||
expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3);
|
||||
expect(html).toContain('class="slide-quote">text t1</p>');
|
||||
expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/);
|
||||
expect(html).toContain("slide-scrim-top");
|
||||
expect(html).toContain('class="slide-text">text i1</p>');
|
||||
expect(html).toContain('aria-label="Play video"');
|
||||
// The quote slide shows its text once, as the quote.
|
||||
expect(html.match(/text t1/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks the first slide as the one on screen", () => {
|
||||
const html = render({});
|
||||
expect(html).toMatch(/class="slide slide-on-text is-cur"/);
|
||||
expect(html).toContain('data-current="0"');
|
||||
});
|
||||
|
||||
it("shows the frosted menu only on the person's own posts", () => {
|
||||
const html = render({});
|
||||
expect(html.match(/slide-menu-btn/g)).toHaveLength(1);
|
||||
expect(html).toContain('aria-label="More actions"');
|
||||
expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn");
|
||||
});
|
||||
|
||||
it("ends with a loading slide while another page can load", () => {
|
||||
expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"');
|
||||
expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"');
|
||||
expect(render({})).not.toContain("feed-slide-load");
|
||||
const failed = render({ hasMore: true, loadMoreFailed: true });
|
||||
expect(failed).not.toContain("feed-slide-load");
|
||||
expect(failed).toContain("Try again");
|
||||
});
|
||||
|
||||
it("shows the loading slide on first load", () => {
|
||||
expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"');
|
||||
});
|
||||
|
||||
it("picks the slide that is mostly on screen", () => {
|
||||
expect(pickCurrent(0, [])).toBe(0);
|
||||
expect(pickCurrent(0, [1])).toBe(1);
|
||||
expect(pickCurrent(2, [1, 2])).toBe(2);
|
||||
expect(pickCurrent(4, [1, 2])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile stylesheet", () => {
|
||||
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||
it("snaps one slide per screen", () => {
|
||||
expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/);
|
||||
expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/);
|
||||
});
|
||||
it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => {
|
||||
expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/);
|
||||
expect(css).toMatch(/\.slide-quote::before \{[^}]*color: var\(--accent\)/);
|
||||
expect(css).toMatch(/transform: scale\(1\.04\)/);
|
||||
expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/);
|
||||
});
|
||||
it("uses tokens only", () => {
|
||||
expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one step per gesture", () => {
|
||||
it("moves one slide in the gesture's direction and stays inside the reel", async () => {
|
||||
const { stepTarget } = await import("@/components/MobileFeed");
|
||||
expect(stepTarget(0, 120, 5)).toBe(1);
|
||||
expect(stepTarget(3, -4, 5)).toBe(2);
|
||||
expect(stepTarget(4, 300, 5)).toBe(4);
|
||||
expect(stepTarget(0, -50, 5)).toBe(0);
|
||||
expect(stepTarget(2, 0, 5)).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user