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 }) {
|
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;
|
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||||
// 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);
|
||||||
@@ -100,7 +134,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
|||||||
>
|
>
|
||||||
{media ? (
|
{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-top" aria-hidden="true" />
|
||||||
<div className="slide-scrim" 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 }],
|
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",
|
type: "post",
|
||||||
publishedAt: "2026-10-18T19:05:00Z",
|
publishedAt: "2026-10-18T19:05:00Z",
|
||||||
|
|||||||
@@ -56,6 +56,40 @@
|
|||||||
.slide-play { z-index: 4; }
|
.slide-play { z-index: 4; }
|
||||||
.slide-dur { top: 128px; right: 16px; 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,
|
||||||
.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; }
|
.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 { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user