Feed browser: Mobile immersive feed #4
@@ -82,8 +82,42 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
|
||||
function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const onScroll = () => {
|
||||
const el = track.current;
|
||||
if (!el) return;
|
||||
const next = Math.min(items.length - 1, Math.max(0, Math.round(el.scrollLeft / Math.max(el.clientWidth, 1))));
|
||||
if (next === active) return;
|
||||
setActive(next);
|
||||
// A video stops when it is swiped away.
|
||||
el.querySelectorAll<HTMLElement>(":scope > .slide-pane").forEach((pane, i) => {
|
||||
if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause());
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}>
|
||||
{items.map((m, i) => (
|
||||
<div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}>
|
||||
<SlideMedia media={m} author={author} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="slide-dots" data-testid="slide-dots" aria-hidden="true">
|
||||
{items.map((_, i) => (
|
||||
<span key={i} className={`slide-dot${i === active ? " slide-dot-on" : ""}`} />
|
||||
))}
|
||||
</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 items = post.media.filter((m) => m.kind === "video" || m.src);
|
||||
const media = items[0] ?? null;
|
||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||
// While a video plays, the author and text step aside so the native controls stay clear.
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -100,7 +134,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
||||
>
|
||||
{media ? (
|
||||
<>
|
||||
<SlideMedia media={media} author={post.authorName} />
|
||||
{items.length > 1 ? <MediaTrack items={items} author={post.authorName} /> : <SlideMedia media={media} author={post.authorName} />}
|
||||
<div className="slide-scrim-top" aria-hidden="true" />
|
||||
<div className="slide-scrim" aria-hidden="true" />
|
||||
</>
|
||||
|
||||
@@ -89,6 +89,22 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
||||
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T21:20:00Z",
|
||||
likesCount: 22,
|
||||
commentsCount: 2,
|
||||
post: {
|
||||
id: "p7",
|
||||
content: "Workshop recap: two slides and the demo recording. Swipe sideways for all three.",
|
||||
author: author("a7", "Leila Haddad"),
|
||||
mediaItems: [
|
||||
{ mediaType: "image", imageUrl: placeholderImage(140, "Slide one"), order: 0 },
|
||||
{ mediaType: "image", imageUrl: placeholderImage(260, "Slide two"), order: 1 },
|
||||
{ mediaType: "video", thumbnailUrl: placeholderImage(20, "Demo recording"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 73, order: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "post",
|
||||
publishedAt: "2026-10-18T19:05:00Z",
|
||||
|
||||
@@ -56,6 +56,40 @@
|
||||
.slide-play { z-index: 4; }
|
||||
.slide-dur { top: 128px; right: 16px; z-index: 4; }
|
||||
|
||||
/* Several media items: a sideways snap track; each pane is one full-slide item. */
|
||||
.slide-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.slide-track::-webkit-scrollbar { display: none; }
|
||||
.slide-pane { position: relative; flex: 0 0 100%; height: 100%; scroll-snap-align: start; scroll-snap-stop: always; }
|
||||
.slide-dots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 84px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.slide-dot {
|
||||
display: block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--on-media-line);
|
||||
transition: width .5s var(--ease), background-color .5s;
|
||||
}
|
||||
.slide-dot-on { width: 18px; background: var(--accent); }
|
||||
|
||||
.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)); }
|
||||
|
||||
Reference in New Issue
Block a user