From d1105cbd4889f5309d448fde28749b897fdc0104 Mon Sep 17 00:00:00 2001 From: Cory O'Brien Date: Thu, 2 Jan 2025 15:11:57 -0500 Subject: [PATCH 01/20] extend dialog to support closing on overlay click when controlled --- src/ui/Dialog.tsx | 78 +++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx index 3176ff7..e312ef8 100644 --- a/src/ui/Dialog.tsx +++ b/src/ui/Dialog.tsx @@ -15,6 +15,7 @@ export type Presets = 'slide-up' | 'scale' | 'subtle'; type DialogContextValue = { isOpen: boolean; + onOpenChange: (updates: boolean) => void; preset: Presets; }; @@ -24,8 +25,7 @@ const DialogContext = createContext( const InnerOverlay = styled(animated.div, { base: { - background: 'rgba(255, 255, 255, 0.68)', - backdropFilter: 'blur(0.125rem)', + background: 'transparent', position: 'fixed', inset: 0, zIndex: 2, @@ -37,7 +37,7 @@ export type OverlayProps = HTMLStyledProps<'div'>; export const Overlay = forwardRef( function Overlay(props, ref) { const { children, ...overlayProps } = props; - const { isOpen } = useContext(DialogContext); + const { isOpen, onOpenChange } = useContext(DialogContext); const transitions = useTransition(isOpen, { from: { opacity: 0 }, enter: { opacity: 1 }, @@ -48,7 +48,11 @@ export const Overlay = forwardRef( return transitions((styles, shouldRender) => { if (shouldRender) { return ( - + onOpenChange(false)} + > {children} @@ -88,17 +92,17 @@ const contentStyle = cva({ }, }); +const presetConfig: SpringConfig = { + ...config.default, + clamp: true, + mass: 1, +}; + const getPresetTransitionConfig = ( preset: Presets, ): Required< Pick, 'from' | 'enter' | 'leave' | 'config'> > => { - const presetConfig: SpringConfig = { - ...config.default, - clamp: true, - mass: 1, - }; - switch (preset) { case 'slide-up': { return { @@ -118,7 +122,10 @@ const getPresetTransitionConfig = ( opacity: 0.84, transform: 'translate3d(-50%, -49%, 0) scale(0.88)', }, - enter: { opacity: 1, transform: 'translate3d(-50%, -50%, 0) scale(1)' }, + enter: { + opacity: 1, + transform: 'translate3d(-50%, -50%, 0) scale(1)', + }, leave: { opacity: 0, transform: 'translate3d(-50%, -47%, 0) scale(0.92)', @@ -162,33 +169,31 @@ export const Content = forwardRef( expires: true, }); - return ( - - {transitions((styles, shouldRender) => { - if (shouldRender) { - return ( - - - {children} - - - ); - } - - return null; - })} - - ); + return transitions((styles, shouldRender) => { + if (shouldRender) { + return ( + + + {children} + + + ); + } + + return null; + }); }, ); export const Title = styled(RxDialog.Title); +export const Trigger = styled(RxDialog.Trigger); +export const Portal = styled(RxDialog.Portal); export type DialogRootProps = Pick< RxDialog.DialogProps, @@ -206,7 +211,6 @@ export const Root = (props: DialogRootProps) => { if (!changes) { onClose(); } - if (onOpenChange) { onOpenChange(changes); } @@ -215,7 +219,9 @@ export const Root = (props: DialogRootProps) => { ); return ( - + {children} From 49813445fdec8d57f743e227fcc19cfd23b58f7b Mon Sep 17 00:00:00 2001 From: Cory O'Brien Date: Thu, 2 Jan 2025 15:22:07 -0500 Subject: [PATCH 02/20] scale content container and expand item when entering forground state --- src/App.tsx | 70 +++++++++++++------ src/feat/Projects/components/ProjectItem.tsx | 73 +++++++++++--------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8045d68..6b5e48c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { animated, useSpring } from '@react-spring/web'; import { usePaginatedQuery } from 'convex/react'; import { type PropsWithChildren } from 'react'; import { Box, Stack } from 'styled-system/jsx'; @@ -40,11 +41,29 @@ const LoadMore = (props: PropsWithChildren & { onClick: () => void }) => ( ); +const ContentContainer = ( + props: PropsWithChildren & { state: 'foreground' | 'background' }, +) => { + const { children, state } = props; + const styles = useSpring({ + from: { opacity: 1, scale: 1 }, + to: + state === 'background' + ? { opacity: 0.5, scale: 0.99 } + : { opacity: 1, scale: 1 }, + }); + return ( + + {children} + + ); +}; + const LOAD_ITEMS_COUNT = 7; const App = () => { const { features } = useFeatureFlags(); - const { openProjectViewer } = useProjectViewer(); + const { isOpen, openProjectViewer, projectId } = useProjectViewer(); const { results: projects, status, @@ -64,29 +83,34 @@ const App = () => { {projects && !isLoading && ( - - - - {projects.map((project) => { - return ( - openProjectViewer(projectId)} - /> - ); - })} - {canLoadMore && ( - loadMore(LOAD_ITEMS_COUNT)}> - More... - - )} + + + + + {projects.map((project) => { + return ( + openProjectViewer(projectId)} + /> + ); + })} + {canLoadMore && ( + loadMore(LOAD_ITEMS_COUNT)}> + More... + + )} + - + )} diff --git a/src/feat/Projects/components/ProjectItem.tsx b/src/feat/Projects/components/ProjectItem.tsx index 9df1dea..37595c5 100644 --- a/src/feat/Projects/components/ProjectItem.tsx +++ b/src/feat/Projects/components/ProjectItem.tsx @@ -1,3 +1,4 @@ +import { animated, useSpring } from '@react-spring/web'; import { useQuery } from 'convex/react'; import { memo, useMemo, useRef, useState } from 'react'; import { type PropsWithChildren } from 'react'; @@ -75,7 +76,6 @@ const InnerPreview = (props: { projectId: ProjectId }) => { export const Preview = (props: PropsWithChildren<{ projectId: ProjectId }>) => { const { children, projectId } = props; const { hasHover } = useInteractions(); - if (!hasHover) { return children; } @@ -95,6 +95,7 @@ export const formatTimestamp = (ts: number) => export interface ProjectItemProps { isPreviewEnabled: boolean; + isSelected: boolean; isViewerEnabled: boolean; item: ProjectEntity; onSelect: (projectId: ProjectId) => void; @@ -102,42 +103,52 @@ export interface ProjectItemProps { export const ProjectItem = memo( function ProjectItem(props) { - const { item, isPreviewEnabled, isViewerEnabled, onSelect } = props; + const { isPreviewEnabled, isSelected, isViewerEnabled, item, onSelect } = + props; const { _id, title, url, category, publishedAt } = item; + const styles = useSpring({ + from: { opacity: 1, marginBlock: '0rem' }, + to: isSelected + ? { opacity: 0, marginBlock: '0.68rem' } + : { opacity: 1, marginBlock: '0rem' }, + }); return ( - - {isPreviewEnabled ? ( - - { - e.preventDefault(); - onSelect(_id); - }, - } - : {})} - > + + + {isPreviewEnabled ? ( + + { + e.preventDefault(); + onSelect(_id); + }, + } + : {})} + > + {title} + + + ) : ( + {title} - - ) : ( - - {title} - - )} - - {category} - - {formatTimestamp(publishedAt)} - + )} + + {category} + + {formatTimestamp(publishedAt)} + + - + ); }, - (prev, next) => prev.item._id === next.item._id, + (prev, next) => + prev.item._id === next.item._id && prev.isSelected === next.isSelected, ); From c6962e34c9c9f57f256e5c1a58e2be5916b7dab4 Mon Sep 17 00:00:00 2001 From: Cory O'Brien Date: Thu, 2 Jan 2025 16:06:52 -0500 Subject: [PATCH 03/20] toggle viewer and preview based on viewport and touch detection --- src/App.tsx | 13 +++---- src/feat/Projects/components/ProjectItem.tsx | 40 ++++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6b5e48c..e73a14b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,8 @@ import { Box, Stack } from 'styled-system/jsx'; import { api } from '~/convex/api'; import { ProjectItem, useProjectViewer } from '~/feat/Projects'; import { Visualization } from '~/feat/Visualization'; -import { FeatureFlags, useFeatureFlags } from '~/lib/features'; import { VizContainer, ContentOverlay, Root } from '~/lib/layout'; +import { useBreakpoints, useInteractions } from '~/lib/viewport'; import { Button } from '~/ui/Button'; import { Link } from '~/ui/Link'; import { Text } from '~/ui/Text'; @@ -62,7 +62,8 @@ const ContentContainer = ( const LOAD_ITEMS_COUNT = 7; const App = () => { - const { features } = useFeatureFlags(); + const { isMobile } = useBreakpoints(); + const { hasHover, hasTouch } = useInteractions(); const { isOpen, openProjectViewer, projectId } = useProjectViewer(); const { results: projects, @@ -91,13 +92,9 @@ const App = () => { return ( openProjectViewer(projectId)} /> diff --git a/src/feat/Projects/components/ProjectItem.tsx b/src/feat/Projects/components/ProjectItem.tsx index 37595c5..fcd6a26 100644 --- a/src/feat/Projects/components/ProjectItem.tsx +++ b/src/feat/Projects/components/ProjectItem.tsx @@ -112,32 +112,32 @@ export const ProjectItem = memo( ? { opacity: 0, marginBlock: '0.68rem' } : { opacity: 1, marginBlock: '0rem' }, }); + const innerContent = ( + { + e.preventDefault(); + onSelect(_id); + }, + } + : {})} + > + {title} + + ); return ( {isPreviewEnabled ? ( - - { - e.preventDefault(); - onSelect(_id); - }, - } - : {})} - > - {title} - - + {innerContent} ) : ( - - {title} - + innerContent )} {category} From 01e19424a76f0b15e92f1562564dac14b22751a3 Mon Sep 17 00:00:00 2001 From: Cory O'Brien Date: Thu, 2 Jan 2025 19:27:52 -0500 Subject: [PATCH 04/20] extract project preview for clarity --- src/feat/Projects/components/Preview.tsx | 87 +++++++++++++++++++ src/feat/Projects/components/ProjectItem.tsx | 89 +------------------- 2 files changed, 90 insertions(+), 86 deletions(-) create mode 100644 src/feat/Projects/components/Preview.tsx diff --git a/src/feat/Projects/components/Preview.tsx b/src/feat/Projects/components/Preview.tsx new file mode 100644 index 0000000..1ba15fa --- /dev/null +++ b/src/feat/Projects/components/Preview.tsx @@ -0,0 +1,87 @@ +import { useQuery } from 'convex/react'; +import { useMemo, useRef, useState } from 'react'; +import { type PropsWithChildren } from 'react'; +import { Box } from 'styled-system/jsx'; +import { api } from '~/convex/api'; +import { debounce } from '~/lib/debounce'; +import { useInteractions } from '~/lib/viewport'; +import * as HoverCard from '~/ui/HoverCard'; +import { Image } from '~/ui/Image'; +import { Loader } from '~/ui/Loader'; +import type { ProjectId } from '../types'; + +const InnerPreview = (props: { projectId: ProjectId }) => { + const { projectId } = props; + const [isLoading, setLoading] = useState(false); + const previewImage = useQuery(api.projects.loadProjectPreview, { projectId }); + const calculatedHeight = Math.max( + 180, + Math.round(256 * previewImage?.aspectRatio || 0), + ); + const hasInitialized = useRef(false); + const toggleLoader = useMemo( + () => + debounce<(loading: boolean) => void>( + (loading = false) => setLoading(loading), + 50, + ), + [], + ); + + if (!hasInitialized.current) { + hasInitialized.current = true; + toggleLoader(true); + } + + return ( + + {previewImage?.alt} { + toggleLoader.cancel(); + setLoading(false); + }} + options={{ width: 256 }} + src={previewImage?.publicUrl} + useHighRes + useAnimation + width="256px" + /> + {isLoading && ( + + + + )} + + ); +}; + +export const Preview = (props: PropsWithChildren<{ projectId: ProjectId }>) => { + const { children, projectId } = props; + const { hasHover } = useInteractions(); + if (!hasHover) { + return children; + } + + return ( + + {children} + + + + + ); +}; diff --git a/src/feat/Projects/components/ProjectItem.tsx b/src/feat/Projects/components/ProjectItem.tsx index fcd6a26..3b7306c 100644 --- a/src/feat/Projects/components/ProjectItem.tsx +++ b/src/feat/Projects/components/ProjectItem.tsx @@ -1,94 +1,11 @@ import { animated, useSpring } from '@react-spring/web'; -import { useQuery } from 'convex/react'; -import { memo, useMemo, useRef, useState } from 'react'; -import { type PropsWithChildren } from 'react'; -import { Box, Stack } from 'styled-system/jsx'; -import { api } from '~/convex/api'; -import { debounce } from '~/lib/debounce'; -import { useInteractions } from '~/lib/viewport'; +import { memo } from 'react'; +import { Stack } from 'styled-system/jsx'; import { Badge } from '~/ui/Badge'; -import * as HoverCard from '~/ui/HoverCard'; -import { Image } from '~/ui/Image'; import { Link } from '~/ui/Link'; -import { Loader } from '~/ui/Loader'; import { Text } from '~/ui/Text'; import type { ProjectEntity, ProjectId } from '../types'; - -const InnerPreview = (props: { projectId: ProjectId }) => { - const { projectId } = props; - const [isLoading, setLoading] = useState(false); - const previewImage = useQuery(api.projects.loadProjectPreview, { projectId }); - const calculatedHeight = Math.max( - 180, - Math.round(256 * previewImage?.aspectRatio || 0), - ); - const hasInitialized = useRef(false); - const toggleLoader = useMemo( - () => - debounce<(loading: boolean) => void>( - (loading = false) => setLoading(loading), - 50, - ), - [], - ); - - if (!hasInitialized.current) { - hasInitialized.current = true; - toggleLoader(true); - } - - return ( - - {previewImage?.alt} { - toggleLoader.cancel(); - setLoading(false); - }} - options={{ width: 256 }} - src={previewImage?.publicUrl} - useHighRes - useAnimation - width="256px" - /> - {isLoading && ( - - - - )} - - ); -}; - -export const Preview = (props: PropsWithChildren<{ projectId: ProjectId }>) => { - const { children, projectId } = props; - const { hasHover } = useInteractions(); - if (!hasHover) { - return children; - } - - return ( - - {children} - - - - - ); -}; +import { Preview } from './Preview'; export const formatTimestamp = (ts: number) => new Date(ts).toLocaleDateString('en-US'); From 97d69a8bdde40329f74a1c999d70a5e27a24ef07 Mon Sep 17 00:00:00 2001 From: Cory O'Brien Date: Thu, 2 Jan 2025 21:38:16 -0500 Subject: [PATCH 05/20] clean up media embed and add cool nondeterministic loading spinner --- src/ui/MediaEmbed.tsx | 89 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/src/ui/MediaEmbed.tsx b/src/ui/MediaEmbed.tsx index 5901b8c..2c0128e 100644 --- a/src/ui/MediaEmbed.tsx +++ b/src/ui/MediaEmbed.tsx @@ -1,4 +1,8 @@ -import { Flex, styled, type HTMLStyledProps } from 'styled-system/jsx'; +import { useState, type PropsWithChildren } from 'react'; +import { Rand } from '@prtcl/plonk'; +import { useMetro } from '@prtcl/plonk-hooks'; +import { Box, Flex, styled, type HTMLStyledProps } from 'styled-system/jsx'; +import { Loader } from './Loader'; export enum Service { YOUTUBE = 'youtube', @@ -14,12 +18,14 @@ export type ServiceProps = HTMLStyledProps<'iframe'> & { frameborder?: string; }; -const getServiceProps = (service: Services): ServiceProps => { +const getServiceProps = (service: Services, embedUrl: string): ServiceProps => { switch (service) { case Service.BANDCAMP: { return { allowtransparency: 'true', - style: { minHeight: '435px' }, + style: { + minHeight: embedUrl.includes('VideoEmbed') ? '380px' : '120px', + }, }; } case Service.YOUTUBE: { @@ -28,7 +34,7 @@ const getServiceProps = (service: Services): ServiceProps => { allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', referrerPolicy: 'strict-origin-when-cross-origin', - style: { minHeight: '315px' }, + style: { minHeight: '300px' }, }; } case Service.SOUNDCLOUD: { @@ -45,6 +51,48 @@ const getServiceProps = (service: Services): ServiceProps => { } }; +const Container = (props: PropsWithChildren) => ( + + {props.children} + +); + +export const Iframe = styled('iframe', { + base: { + background: 'black', + border: 0, + borderRadius: 0, + borderStyle: 'none', + display: 'block', + margin: 0, + outline: 'none', + padding: 0, + width: '100%', + }, +}); + +// Because iframe's and CORS and inner content loading flicker +const useFakeLoader = () => { + const [isLoading, toggleLoading] = useState(true); + const [delay] = useState(() => new Rand({ min: 300, max: 500 })); + useMetro((timer) => { + if (timer.state.totalElapsed >= delay.value()) { + timer.stop(); + toggleLoading(false); + } + }); + + return { isLoading }; +}; + export interface MediaEmbedProps { src: string; service: Services; @@ -53,27 +101,32 @@ export interface MediaEmbedProps { export const MediaEmbed = (props: MediaEmbedProps) => { const { src, service, title = 'Video' } = props; - const serviceProps = getServiceProps(service); + const { isLoading } = useFakeLoader(); + const serviceProps = getServiceProps(service, src); return ( - - +