diff --git a/src/app/globals.css b/src/app/globals.css index 94cda99..bbc11b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 95a647b..1d69a50 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; import { Cormorant_Garamond, Manrope } from "next/font/google"; import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme"; @@ -14,6 +14,8 @@ import "../styles/featured.css"; import "../styles/feed.css"; import "../styles/trending.css"; import "../styles/search.css"; +import "../styles/mobile.css"; +import "../styles/sheet.css"; import "../styles/legacy.css"; const manrope = Manrope({ @@ -31,6 +33,9 @@ const cormorant = Cormorant_Garamond({ display: "swap", }); +// viewport-fit=cover makes env(safe-area-inset-*) report the iPhone home indicator. +export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" }; + export const metadata: Metadata = { title: "Minnect feed browser", description: "Sign in with your Minnect account to read and write the home feed.", diff --git a/src/components/App.tsx b/src/components/App.tsx index 1ce4491..c1d9b95 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -5,7 +5,7 @@ import { loadSession, type Person } from "@/lib/auth-client"; import { SignInForm } from "./SignInForm"; import { AppShell } from "./AppShell"; import { Feed } from "./Feed"; -import { DiscoverRails } from "./DiscoverRails"; +import { DiscoverRailsView, useDiscover } from "./DiscoverRails"; import { ExpertSearch } from "./ExpertSearch"; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; @@ -39,12 +39,18 @@ export function App() { }); }} />; } + return 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 ( } - feed={ setState({ kind: "out" })} />} - discover={} + feed={} />} + discover={} /> ); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 36935be..2ad627d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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,18 +74,36 @@ 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(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 ( -
+ +
- {search} + {compact ? null : search}
{feed}
+
); } diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx new file mode 100644 index 0000000..4ac05a9 --- /dev/null +++ b/src/components/BottomSheet.tsx @@ -0,0 +1,90 @@ +"use client"; + +// Bottom sheet (spec "Sheets"): slides up over a dimmed, blurred backdrop with a +// grab handle, a serif title and a close button. Reused by the comments, +// composer and Discover sheets. Styles live in src/styles/sheet.css. +import { useEffect, useId, useRef, type ReactNode } from "react"; + +const FOCUSABLE = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'; + +export interface BottomSheetProps { + title: string; + onClose: () => void; + children: ReactNode; + /** Extra class on the sheet, for per-sheet sizing. */ + className?: string; +} + +export function BottomSheet({ title, onClose, children, className }: BottomSheetProps) { + const sheet = useRef(null); + const titleId = useId(); + const closeRef = useRef(onClose); + closeRef.current = onClose; + + useEffect(() => { + const el = sheet.current; + if (!el) return; + const opener = document.activeElement as HTMLElement | null; + // Focus moves into the sheet: its first field, else the sheet itself. + const first = el.querySelector("input, textarea") ?? el.querySelector(FOCUSABLE); + (first ?? el).focus({ preventScroll: true }); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + // An open emoji popover closes first; it handles Escape itself. + if (el?.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]')) return; + e.preventDefault(); + closeRef.current(); + return; + } + if (e.key !== "Tab" || !el) return; + const items = [...el.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement); + if (!items.length) return; + const a = items[0]; + const z = items[items.length - 1]; + if (e.shiftKey && (document.activeElement === a || document.activeElement === el)) { + e.preventDefault(); + z.focus(); + } else if (!e.shiftKey && document.activeElement === z) { + e.preventDefault(); + a.focus(); + } + } + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + // Focus returns to the opener (when it is still on the page). + if (opener && opener.isConnected) opener.focus({ preventScroll: true }); + }; + }, []); + + return ( + <> + +
+
{children}
+ + + ); +} diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 41ac174..6ec6719 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -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 ( + ))} + + ); +} + +export interface DiscoverSheetProps { + experts: DiscoverExpert[]; + onOpen: (panel: DiscoverPanel) => void; + onClose: () => void; +} + +export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) { + return ( + + {experts.length > 0 ? ( + onOpen("featured")} + leading={ + + } + trailing={} + /> + ) : null} + } trailing={} onClick={() => onOpen("trending")} /> + } trailing={} onClick={() => onOpen("search")} /> + Appearance + + + ); +} + +export interface DiscoverPanelSheetProps { + panel: DiscoverPanel; + experts: DiscoverExpert[]; + trending: DiscoverMinnect[]; + search?: ReactNode; + onClose: () => void; +} + +const TITLES: Record = { + 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 ( + + {panel === "featured" ? : null} + {panel === "trending" ? : null} + {panel === "search" ? search : null} + + ); +} diff --git a/src/components/FeaturedCarousel.tsx b/src/components/FeaturedCarousel.tsx index 3f3ea72..d44838b 100644 --- a/src/components/FeaturedCarousel.tsx +++ b/src/components/FeaturedCarousel.tsx @@ -46,9 +46,11 @@ export interface FeaturedCarouselProps { experts: DiscoverExpert[]; /** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */ 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 [index, setIndex] = useState(0); const reduced = useReducedMotion(); @@ -79,23 +81,27 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) const current = index % count; const cap = experts[current]; const style = { "--cfw": `${size}px` } as CSSProperties; + const bare = variant === "slide"; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }} data-paused={paused ? "true" : undefined} > -
- - {`${current + 1} / ${count}`} -
+ {bare ? null : ( +
+ + {`${current + 1} / ${count}`} +
+ )}
{experts.map((e, i) => { const slot = featuredSlot(i, current, count); diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 3dafa9b..60f6211 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -6,8 +6,11 @@ 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 { 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"; const POLL_MS = 45_000; @@ -15,11 +18,19 @@ export function Feed({ onSignedOut, feedUserId, person, + featuredExperts, + trendingMinnects, + searchPanel, }: { onSignedOut: () => void; feedUserId?: string | null; /** The signed-in person, shown beside the composer. */ 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([]); const [status, setStatus] = useState("loading"); @@ -44,6 +55,9 @@ export function Feed({ const list = useRef(null); const busy = useRef(false); const shown = useRef>(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 +131,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 +290,10 @@ export function Feed({ deleteErrors={deleteErrors} 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} /> diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 99e4d2c..777951d 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -1,8 +1,13 @@ +"use client"; + import type { ReactNode, Ref } from "react"; import { canDelete, type CardPost } from "@/lib/feed-model"; +import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; 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 +38,22 @@ export interface FeedViewProps { /** The scrolling feed list; the page itself never scrolls. */ listRef?: Ref; now?: number; + /** Another page can load; the phone feed ends with a loading slide. */ + 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. */ + onOpenComments?: (postId: string) => void; + /** Phone layout only: the dock's new-post button. */ + onOpenComposer?: () => void; + /** Phone layout only: the dock's More button (the Discover sheet). */ + onOpenMore?: () => void; } function Spinner() { @@ -104,6 +125,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 ; if (p.status === "loading") { return ( diff --git a/src/components/MobileDock.tsx b/src/components/MobileDock.tsx new file mode 100644 index 0000000..481cec1 --- /dev/null +++ b/src/components/MobileDock.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { commentsLabel } from "@/lib/feed-model"; +import { SparkleButton } from "./Buttons"; +import type { CurrentSlide } from "./MobileFeed"; +import type { CardPost } from "@/lib/feed-model"; + +const icon = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.7, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +}; + +export interface MobileDockProps { + /** The slide on screen; like and comments act on its post. */ + slide: CurrentSlide; + onToggleLike?: (post: CardPost) => void; + likeBusy?: boolean; + onOpenComments?: (postId: string) => void; + onOpenComposer?: () => void; + onOpenMore?: () => void; +} + +/** What the like and comment buttons show and whether they work, for the slide on screen. */ +export function dockState(slide: CurrentSlide, opts: { canLike?: boolean; likeBusy?: boolean } = {}) { + const post = slide.kind === "post" ? slide.post : null; + if (!post) { + return { post: null, liked: false, likes: null, comments: null, likeLabel: "Like", commentsLabel: "Comments", likeDisabled: true, commentsDisabled: true }; + } + return { + post, + liked: post.isLiked, + likes: post.likesCount, + comments: post.commentsCount, + likeLabel: post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`, + commentsLabel: `Open comments, ${commentsLabel(post.commentsCount).toLowerCase()}`, + likeDisabled: !post.id || !opts.canLike || !!opts.likeBusy, + commentsDisabled: !post.id, + }; +} + +/** + * Phone dock (spec "Mobile layout" > "Bottom dock"): a frosted bar 16px above the + * bottom. Like and comments act on the post on screen and are disabled on the + * featured and loading slides. Liked state and counts come from the post itself, + * the same source as the desktop like button and double-tap. + */ +export function MobileDock({ slide, onToggleLike, likeBusy, onOpenComments, onOpenComposer, onOpenMore }: MobileDockProps) { + const s = dockState(slide, { canLike: !!onToggleLike, likeBusy }); + const [beat, setBeat] = useState(false); + return ( + + ); +} diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx new file mode 100644 index 0000000..91e55db --- /dev/null +++ b/src/components/MobileFeed.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type MouseEvent, 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 { FeaturedCarousel } from "./FeaturedCarousel"; +import { Logo } from "./Logo"; +import { useTheme } from "@/lib/use-theme"; +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"; + +/** The slide on screen: its index in the reel and its post (null on the loading slide). */ +export interface CurrentSlide { + 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; +} + +/** 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. */ +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(ref: Ref | 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(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 ( +
+
+ +
+ {started ? null : ( + <> + + {duration ? {duration} : null} + + )} +
+ ); + } + if (media.src) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {media.kind +
+ ); + } + return ( +
+ Video is processing +
+ ); +} + +/** 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(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(":scope > .slide-pane").forEach((pane, i) => { + if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause()); + }); + }; + return ( + <> +
+ {items.map((m, i) => ( +
+ +
+ ))} +
+ + + ); +} + +function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) { + 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); + const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`; + const lastTap = useRef(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) => { + 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 ( +
setPlaying(true)} + onPauseCapture={() => setPlaying(false)} + onEndedCapture={() => setPlaying(false)} + > + {media ? ( + <> + {items.length > 1 ? : } +
+ ); +} + +/** Height of the progress rail, as a percent: the slide on screen out of the loaded slides. */ +export function railPercent(index: number, count: number): number { + if (count <= 0) return 0; + return Math.round((Math.min(Math.max(index, 0), count - 1) + 1) / count * 100); +} + +const topIcon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const; + +/** Frosted pills over the feed (spec "Top bar"): logo and wordmark left, theme toggle right. */ +function TopBar() { + const { theme, toggleTheme } = useTheme(); + const next = theme === "dark" ? "light" : "dark"; + return ( +
+
+ + MINNECT +
+ +
+ ); +} + +function ChevronDown() { + return ( + + ); +} + +/** 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 ( +
+ Featured experts +

Ask the people everyone is talking about

+
+ +
+ + + Keep scrolling + +
+ ); +} + +function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref }) { + return ( +
+
+ ); +} + +/** + * 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(null); + const [current, setCurrent] = useState(0); + // 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); + const composerBusy = !!p.composer?.busy; + const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file; + useEffect(() => { + if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s)); + wasBusy.current = composerBusy; + }, [composerBusy, composerClear]); + const onCurrentChange = p.onCurrentChange; + const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore)); + 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. + useEffect(() => { + const root = reel.current; + if (!root || typeof IntersectionObserver === "undefined") return; + const ratios = new Map(); + 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(":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 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"; + + const onMedia = !!currentPost && currentPost.media.some((m) => m.kind === "video" || m.src); + + useEffect(() => { + onCurrentChange?.({ index, kind, post: currentPost }); + }, [index, kind, currentPost, onCurrentChange]); + + // A video keeps playing only while its slide is on screen. + useEffect(() => { + reel.current?.querySelectorAll(":scope > .slide").forEach((el) => { + if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause()); + }); + }, [index]); + + return ( +
+ {p.newCount > 0 && p.status === "ready" ? ( +
+ +
+ ) : null} + + +
{ + reel.current = el; + setRef(p.listRef, el); + }} + > + {p.status === "error" ? ( +
+

The feed did not load.

+ +
+ ) : null} + {p.status === "ready" && p.posts.length === 0 && !showLoad ? ( +
+

No posts yet.

+
+ ) : null} + {p.status === "ready" + ? p.posts.flatMap((post, i) => { + const node = ; + return i === featuredAt - 1 + ? [node, ] + : [node]; + }) + : null} + {p.status === "ready" && p.loadMoreFailed ? ( +
+

The feed did not load.

+ +
+ ) : null} + {showLoad ? ( + + ) : null} +
+ {p.status !== "error" ? ( + { + setSheet({ kind: "comments", postId }); + p.onOpenComments?.(postId); + }} + onOpenComposer={() => { + setSheet({ kind: "composer" }); + p.onOpenComposer?.(); + }} + onOpenMore={() => { + setSheet({ kind: "more" }); + p.onOpenMore?.(); + }} + /> + ) : null} + {sheet?.kind === "comments" ? ( + + + + ) : null} + {sheet?.kind === "composer" && p.composer ? ( + + + + ) : null} + {sheet?.kind === "more" ? ( + setSheet({ kind: "panel", panel })} onClose={closeSheet} /> + ) : null} + {sheet?.kind === "panel" ? ( + + ) : null} +
+ ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 247cc1e..21e4d9a 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -59,7 +59,7 @@ function TrashIcon() { ); } -function PlayIcon() { +export function PlayIcon() { return (
+ + {menu ? ( +
+ {confirming ? ( +
+

Delete this post?

+

This cannot be undone.

+
+ { setConfirming(false); setMenu(false); }} + /> + } + size="sm" + disabled={deleteBusy} + onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }} + /> +
+
+ ) : ( + + )} +
+ ) : 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 (
@@ -123,53 +185,7 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
- {own ? ( -
- - {menu ? ( -
- {confirming ? ( -
-

Delete this post?

-

This cannot be undone.

-
- { setConfirming(false); setMenu(false); }} - /> - } - size="sm" - disabled={deleteBusy} - onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }} - /> -
-
- ) : ( - - )} -
- ) : null} -
- ) : null} + {own ? : null} {deleteError ?

Could not delete the post.

: null} {post.text ?

{post.text}

: null} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 8c2c73e..ff2220a 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -8,6 +8,8 @@ export interface VideoPlayerProps { hlsUrl?: string | null; poster: string | null; label: string; + /** Native controls; off until the caller's own play button starts the video. Default on. */ + controls?: boolean; } const UNPLAYABLE = "Video can't be played here."; @@ -18,7 +20,7 @@ const UNPLAYABLE = "Video can't be played here."; * unsupported), else thumbnail only. Never * autoplays. Any failure falls back to the thumbnail with a short note. */ -export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) { +export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) { const ref = useRef(null); const [failed, setFailed] = useState(false); @@ -77,7 +79,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)