diff --git a/convex/projects.ts b/convex/projects.ts index 135dcbe..69cb092 100644 --- a/convex/projects.ts +++ b/convex/projects.ts @@ -17,20 +17,6 @@ export const loadProjects = query({ }, }); -export const loadProjectIds = query({ - args: {}, - handler: async (ctx) => { - const projects = await ctx.db - .query('projects') - .withIndex('deletedByOrder', (q) => q.eq('deletedAt', null)) - .filter((q) => q.neq(q.field('publishedAt'), null)) - .order('asc') - .collect(); - - return projects?.map((p) => p._id); - }, -}); - const getProjectOrNotFound = async ( ctx: QueryCtx, projectId: Id<'projects'>, diff --git a/src/App.tsx b/src/App.tsx index 8045d68..57b9949 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,12 @@ 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'; +import { FeatureFlags, useFeatureFlags } from './lib/features'; const Bio = () => { return ( @@ -40,11 +41,34 @@ const LoadMore = (props: PropsWithChildren & { onClick: () => void }) => ( ); +const ContentContainer = ( + props: PropsWithChildren & { state: 'foreground' | 'background' }, +) => { + const { children, state } = props; + return ( + + {children} + + ); +}; + const LOAD_ITEMS_COUNT = 7; const App = () => { const { features } = useFeatureFlags(); - const { openProjectViewer } = useProjectViewer(); + const { isMobile } = useBreakpoints(); + const { hasTouch } = useInteractions(); + const { isOpen, openProjectViewer, projectId } = useProjectViewer(); const { results: projects, status, @@ -64,29 +88,40 @@ const App = () => { {projects && !isLoading && ( - - - - {projects.map((project) => { - return ( - openProjectViewer(projectId)} - /> - ); - })} - {canLoadMore && ( - loadMore(LOAD_ITEMS_COUNT)}> - More... - - )} + + + + + {projects.map((project) => { + return ( + { + if ((isMobile || hasTouch) && !!project.embedId) { + openProjectViewer( + project, + target.getBoundingClientRect(), + ); + } else { + window.open(project.url, '_blank'); + } + }} + /> + ); + })} + {canLoadMore && ( + loadMore(LOAD_ITEMS_COUNT)}> + More... + + )} + - + )} diff --git a/src/feat/Projects/ProjectProvider.tsx b/src/feat/Projects/ProjectProvider.tsx index 0ece96d..c76daa6 100644 --- a/src/feat/Projects/ProjectProvider.tsx +++ b/src/feat/Projects/ProjectProvider.tsx @@ -1,28 +1,13 @@ import { type PropsWithChildren } from 'react'; import { ProjectViewer } from './ProjectViewer'; -import { NextPrevContext, useNextPrevApi } from './hooks/useNextPrev'; import { ProjectViewerContext, useProjectViewerState, } from './hooks/useProjectViewer'; -const ProjectViewerProvider = (props: PropsWithChildren) => ( +export const ProjectProvider = (props: PropsWithChildren) => ( {props.children} + ); - -const NextPrevProvider = (props: PropsWithChildren) => ( - - {props.children} - -); - -export const ProjectProvider = (props: PropsWithChildren) => ( - - - {props.children} - - - -); diff --git a/src/feat/Projects/ProjectViewer.tsx b/src/feat/Projects/ProjectViewer.tsx index ea7879d..6682bd3 100644 --- a/src/feat/Projects/ProjectViewer.tsx +++ b/src/feat/Projects/ProjectViewer.tsx @@ -1,113 +1,59 @@ -import { useQuery } from 'convex/react'; -import { useState } from 'react'; -import useKey from 'react-use/lib/useKey'; -import { Flex, Stack, VisuallyHidden } from 'styled-system/jsx'; -import { api } from '~/convex/api'; +import { forwardRef } from 'react'; +import { createPortal } from 'react-dom'; +import { Flex, type FlexProps } from 'styled-system/jsx'; import { ErrorBoundary } from '~/lib/errors'; -import * as Dialog from '~/ui/Dialog'; -import { IconButton } from '~/ui/IconButton'; import { Text } from '~/ui/Text'; -import { BackIcon, ChevronLeftIcon } from '~/ui/icons'; -import { - PanelContainer, - PanelFooter, - PanelHeader, -} from './components/PanelContainer'; -import { ProjectDetails } from './components/ProjectDetails'; -import { ProjectUrl } from './components/ProjectUrl'; -import { useNextPrev, type Directions } from './hooks/useNextPrev'; -import { useProjectViewer } from './hooks/useProjectViewer'; +import { useProjectViewer, ViewerType } from './hooks/useProjectViewer'; +import { EmbedViewer } from './ui/EmbedViewer'; -const DetailsPanel = () => { - const { projectId, updateProjectId } = useProjectViewer(); - const project = useQuery(api.projects.loadProject, { projectId }); - const { next, prev } = useNextPrev(projectId); - const [direction, setDirection] = useState(0); - - const handleLeft = () => { - if (prev) { - updateProjectId(prev); - setDirection(-1); - } - }; - const handleRight = () => { - if (next) { - updateProjectId(next); - setDirection(1); +const Overlay = forwardRef( + function Overlay(props, ref) { + const { children, ...flexProps } = props; + const { closeViewer, isOpen } = useProjectViewer(); + if (!isOpen) { + return null; } - }; - useKey('ArrowLeft', handleLeft, {}, [prev]); - useKey('ArrowRight', handleRight, {}, [next]); - return ( - <> - - - - - - handleLeft()} isDisabled={prev === null}> - - - handleRight()} - isDisabled={next === null} - > - - - - + return ( + closeViewer()} + onTouchMove={() => closeViewer()} + {...flexProps} + > + {children} - - {project?.title} - - - ); -}; - -const NotFound = () => ( - - Not Found - + ); + }, ); -export const ProjectViewer = () => { - const { isOpen, closeViewer } = useProjectViewer(); +const NotFound = () => + createPortal( + + Not Found + , + document.body, + ); +export const ProjectViewer = () => { + const { isOpen, projectId, viewerType, origin } = useProjectViewer(); return ( - - - e.preventDefault()} - overflow="hidden" - shadow="2xl" - width="100%" - > - - - closeViewer()} size="md"> - - - - }> - - - - - + }> + {createPortal( + <> + + {[ViewerType.SOUND, ViewerType.VIDEO].includes(viewerType) && ( + + )} + , + document.body, + )} + ); }; diff --git a/src/feat/Projects/components/PanelContainer.tsx b/src/feat/Projects/components/PanelContainer.tsx deleted file mode 100644 index 9178cd8..0000000 --- a/src/feat/Projects/components/PanelContainer.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { type PropsWithChildren } from 'react'; -import { Flex, Stack } from 'styled-system/jsx'; - -export const PanelContainer = (props: PropsWithChildren) => { - const { children } = props; - - return ( - - {children} - - ); -}; - -export const PanelHeader = (props: PropsWithChildren) => { - const { children } = props; - - return ( - - {children} - - ); -}; - -export const PanelFooter = (props: PropsWithChildren) => { - const { children } = props; - - return ( - - {children} - - ); -}; diff --git a/src/feat/Projects/components/ProjectDetails.tsx b/src/feat/Projects/components/ProjectDetails.tsx deleted file mode 100644 index b709432..0000000 --- a/src/feat/Projects/components/ProjectDetails.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { - animated, - useTransition, - type TransitionFrom, - type TransitionTo, -} from '@react-spring/web'; -import { useQuery } from 'convex/react'; -import { useState } from 'react'; -import useMeasure from 'react-use/lib/useMeasure'; -import { Flex, type FlexProps } from 'styled-system/jsx'; -import { api } from '~/convex/api'; -import { Image } from '~/ui/Image'; -import { Loader } from '~/ui/Loader'; -import { Markdown } from '~/ui/Markdown'; -import { MediaEmbed } from '~/ui/MediaEmbed'; -import type { Directions } from '../hooks/useNextPrev'; -import type { - ProjectId, - ContentEntity, - CoverImageEntity, - EmbedCodeEntity, -} from '../types'; - -const fromTransition = (direction: Directions): TransitionFrom => { - const distance = 25; - if (direction !== 0) { - return { - transform: `translate3d(${direction === 1 ? distance : -distance}%, 0, 0)`, - opacity: 0.68, - }; - } - - return { - transform: 'translate3d(0%, 0, 0)', - opacity: 0, - }; -}; - -const toTransition = (direction: Directions): TransitionTo => { - const distance = 25; - if (direction !== 0) { - return { - transform: `translate3d(${direction === 1 ? -distance : distance}%, 0, 0)`, - opacity: 0, - }; - } - - return { - transform: 'translate3d(0%, 0, 0)', - opacity: 0, - }; -}; - -const ScrollContainer = (props: FlexProps) => { - const { children, ...flexProps } = props; - - return ( - - {children} - - ); -}; - -const CoverImage = (props: { - coverImage: CoverImageEntity; - useAnimation: boolean; - onLoad: () => void; -}) => { - const { coverImage, onLoad, useAnimation = false } = props; - const [containerRef, containerRect] = useMeasure(); - const [isLoading, setLoading] = useState(true); - const { aspectRatio } = coverImage; - const calculatedHeight = containerRect.width - ? Math.min( - Math.round(containerRect.width * aspectRatio), - containerRect.height, - ) - : 0; - - return ( - - {coverImage.alt} { - setLoading(false); - onLoad(); - }} - options={{ width: 1280, quality: 75, fit: 'cover' }} - src={coverImage.publicUrl} - style={{ minHeight: `${calculatedHeight}px` }} - useAnimation={useAnimation} - width="100%" - _selection={{ bg: 'transparent' }} - /> - {isLoading && ( - - )} - - ); -}; - -const EmbedCode = (props: { embedCode: EmbedCodeEntity }) => { - const { embedCode } = props; - - return ( - - - - ); -}; - -const Content = (props: { content: ContentEntity }) => { - const { content } = props; - - return ( - - {content.content} - - ); -}; - -const checkLoading = (...items: unknown[]) => - items.some((item) => typeof item === 'undefined'); - -const InnerDetails = (props: { - projectId: ProjectId; - hasSeenImages: Set; -}) => { - const { projectId, hasSeenImages } = props; - const coverImage = useQuery(api.projects.loadProjectCoverImage, { - projectId, - }); - const content = useQuery(api.projects.loadProjectContent, { projectId }); - const embedCode = useQuery(api.projects.loadProjectEmbed, { projectId }); - const isLoading = checkLoading(coverImage, content, embedCode); - - return ( - - {coverImage && ( - hasSeenImages.add(projectId)} - useAnimation={!hasSeenImages.has(projectId)} - /> - )} - {content && } - {embedCode && } - - ); -}; - -export const ProjectDetails = (props: { - direction: Directions; - projectId: ProjectId; -}) => { - const { direction, projectId } = props; - const [hasSeenImages] = useState>(() => new Set()); - const transitions = useTransition(projectId, { - from: fromTransition(direction), - enter: { - transform: 'translate3d(0%, 0, 0)', - opacity: 1, - }, - leave: toTransition(direction), - }); - - return ( - - {transitions((style, currentId) => ( - - - - ))} - - ); -}; diff --git a/src/feat/Projects/components/ProjectUrl.tsx b/src/feat/Projects/components/ProjectUrl.tsx deleted file mode 100644 index a3752b6..0000000 --- a/src/feat/Projects/components/ProjectUrl.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - animated, - useTransition, - type TransitionFrom, - type TransitionTo, -} from '@react-spring/web'; -import { Flex } from 'styled-system/jsx'; -import { Link } from '~/ui/Link'; -import type { Directions } from '../hooks/useNextPrev'; - -const formatProjectUrl = (content: string): string => { - const url = new URL(content); - let path = url.pathname; - - if (path[path.length - 1] === '/') { - path = path.slice(0, -1); - } - - return `${url.host.replace('www.', '')}${path}${url.search}`; -}; - -const fromTransition = (direction: Directions): TransitionFrom => { - const distance = 2; - if (direction !== 0) { - return { - transform: `translate3d(${direction === 1 ? distance : -distance}%, 0, 0)`, - opacity: 0.68, - }; - } - - return { - transform: 'translate3d(0%, 0, 0)', - opacity: 0, - }; -}; - -const toTransition = (direction: Directions): TransitionTo => { - const distance = 2; - if (direction !== 0) { - return { - transform: `translate3d(${direction === 1 ? -distance : distance}%, 0, 0)`, - opacity: 0, - position: 'absolute', - }; - } - - return { - transform: 'translate3d(0%, 0, 0)', - opacity: 0, - }; -}; - -export const ProjectUrl = (props: { url?: string; direction: Directions }) => { - const { url, direction } = props; - const transitions = useTransition(url, { - from: fromTransition(direction), - enter: { - transform: 'translate3d(0%, 0, 0)', - opacity: 1, - }, - leave: toTransition(direction), - }); - - return ( - - {transitions((style, projectUrl) => ( - <> - {projectUrl && ( - - - » {formatProjectUrl(projectUrl)} - - - )} - - ))} - - ); -}; diff --git a/src/feat/Projects/hooks/useNextPrev.ts b/src/feat/Projects/hooks/useNextPrev.ts deleted file mode 100644 index 23e3361..0000000 --- a/src/feat/Projects/hooks/useNextPrev.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery } from 'convex/react'; -import { createContext, useContext, useMemo } from 'react'; -import { api } from '~/convex/api'; -import type { ProjectId } from '../types'; - -export type NextPrev = { - next: ProjectId | null; - prev: ProjectId | null; -}; - -export type NextPrevContextValue = { - nextPrev: Map; - projectIds: ProjectId[]; -}; - -export type Directions = -1 | 1 | 0; - -export const NextPrevContext = createContext( - {} as NextPrevContextValue, -); - -export const useNextPrevApi = (): NextPrevContextValue => { - const projectIds = useQuery(api.projects.loadProjectIds); - const { nextPrev } = useMemo(() => { - return (projectIds || []).reduce<{ - nextPrev: Map; - prev: ProjectId | null; - }>( - (res, projectId, index) => { - res.nextPrev.set(projectId, { - next: projectIds[index + 1] || null, - prev: res.prev || null, - }); - res.prev = projectId; - - return res; - }, - { - nextPrev: new Map(), - prev: null, - }, - ); - }, [projectIds]); - - return useMemo( - () => ({ - nextPrev, - projectIds, - }), - [nextPrev, projectIds], - ); -}; - -export const useNextPrev = (projectId: ProjectId): NextPrev => { - const { nextPrev } = useContext(NextPrevContext); - return { - ...nextPrev.get(projectId), - }; -}; diff --git a/src/feat/Projects/hooks/useProjectViewer.ts b/src/feat/Projects/hooks/useProjectViewer.ts index aecee48..828a7d6 100644 --- a/src/feat/Projects/hooks/useProjectViewer.ts +++ b/src/feat/Projects/hooks/useProjectViewer.ts @@ -5,17 +5,24 @@ import { useMemo, useReducer, } from 'react'; -import type { ProjectId } from '../types'; +import type { ProjectEntity, ProjectId } from '../types'; + +export enum ViewerType { + SOUND = 'sound', + VIDEO = 'video', + TEXT = 'text', +} export type ProjectViewerState = { isOpen: boolean; - projectId: ProjectId; + projectId: ProjectId | null; + viewerType: ViewerType | null; + origin: DOMRect | null; }; export type ProjectViewerContextValue = ProjectViewerState & { + openProjectViewer: (project: ProjectEntity, origin: DOMRect) => void; closeViewer: () => void; - openProjectViewer: (projectId: ProjectId) => void; - updateProjectId: (projectId: ProjectId) => void; }; export const ProjectViewerContext = createContext( @@ -23,45 +30,31 @@ export const ProjectViewerContext = createContext( ); export enum Actions { - SHOW_PROJECT_DETAILS = 'SHOW_PROJECT_DETAILS', - CLOSE = 'CLOSE', - UPDATE_PROJECT_ID = 'UPDATE_PROJECT_ID', + OPEN_PROJECT_VIEWER = 'OPEN_PROJECT_VIEWER', + CLOSE_VIEWER = 'CLOSE_VIEWER', } type ProjectViewerActions = - | { type: Actions.CLOSE } | { - type: Actions.SHOW_PROJECT_DETAILS; - payload: { projectId: ProjectId }; + type: Actions.OPEN_PROJECT_VIEWER; + payload: { + projectId: ProjectId; + viewerType: ViewerType; + origin: DOMRect; + }; } - | { - type: Actions.UPDATE_PROJECT_ID; - payload: { projectId: ProjectId }; - }; + | { type: Actions.CLOSE_VIEWER }; const reducer = ( state: ProjectViewerState, action: ProjectViewerActions, ): ProjectViewerState => { switch (action.type) { - case Actions.SHOW_PROJECT_DETAILS: { - return { - ...state, - isOpen: true, - projectId: action.payload.projectId, - }; + case Actions.OPEN_PROJECT_VIEWER: { + return { ...state, ...action.payload, isOpen: true }; } - case Actions.UPDATE_PROJECT_ID: { - return { - ...state, - projectId: action.payload.projectId, - }; - } - case Actions.CLOSE: { - return { - ...state, - isOpen: false, - }; + case Actions.CLOSE_VIEWER: { + return { ...state, isOpen: false }; } default: { return state; @@ -69,34 +62,42 @@ const reducer = ( } }; -export const useProjectViewerState = (): ProjectViewerContextValue => { - const [state, dispatch] = useReducer(reducer, {}, () => ({ - isOpen: false, - projectId: null, - })); +const getViewerType = (project: ProjectEntity): ViewerType => { + if (project.embedId) { + if (project.category === 'sound') { + return ViewerType.SOUND; + } + if (project.category === 'video') { + return ViewerType.VIDEO; + } + } + return ViewerType.TEXT; +}; - const openProjectViewer = useCallback( - (projectId: ProjectId) => { - dispatch({ - type: Actions.SHOW_PROJECT_DETAILS, - payload: { projectId }, - }); - }, - [dispatch], - ); +const getInitialState = (): ProjectViewerState => ({ + isOpen: false, + origin: null, + projectId: null, + viewerType: null, +}); - const updateProjectId = useCallback( - (projectId: ProjectId) => { +export const useProjectViewerState = (): ProjectViewerContextValue => { + const [state, dispatch] = useReducer(reducer, {}, () => getInitialState()); + const openProjectViewer = useCallback( + (projectId: ProjectEntity, origin: DOMRect) => { dispatch({ - type: Actions.UPDATE_PROJECT_ID, - payload: { projectId }, + type: Actions.OPEN_PROJECT_VIEWER, + payload: { + projectId: projectId._id, + viewerType: getViewerType(projectId), + origin, + }, }); }, [dispatch], ); - const closeViewer = useCallback(() => { - dispatch({ type: Actions.CLOSE }); + dispatch({ type: Actions.CLOSE_VIEWER }); }, [dispatch]); return useMemo( @@ -104,9 +105,8 @@ export const useProjectViewerState = (): ProjectViewerContextValue => { ...state, closeViewer, openProjectViewer, - updateProjectId, }), - [state, openProjectViewer, updateProjectId, closeViewer], + [state, openProjectViewer, closeViewer], ); }; diff --git a/src/feat/Projects/index.ts b/src/feat/Projects/index.ts index 7be08b5..db6b76e 100644 --- a/src/feat/Projects/index.ts +++ b/src/feat/Projects/index.ts @@ -1,5 +1,5 @@ export * from './ProjectViewer'; export * from './ProjectProvider'; export * from './hooks/useProjectViewer'; -export * from './components/ProjectItem'; +export * from './ui/ProjectItem'; export * from './types'; diff --git a/src/feat/Projects/ui/EmbedViewer.tsx b/src/feat/Projects/ui/EmbedViewer.tsx new file mode 100644 index 0000000..6513af2 --- /dev/null +++ b/src/feat/Projects/ui/EmbedViewer.tsx @@ -0,0 +1,100 @@ +import { animated, useTransition } from '@react-spring/web'; +import { useQuery } from 'convex/react'; +import { type CSSProperties } from 'react'; +import { Flex, styled, type FlexProps } from 'styled-system/jsx'; +import { api } from '~/convex/api'; +import { MediaEmbed, Service } from '~/ui/MediaEmbed'; +import type { EmbedCodeEntity, ProjectId } from '../types'; + +const Container = (props: FlexProps) => { + const { children, ...flexProps } = props; + return ( + + + {children} + + + ); +}; + +const ViewerPosition = styled(animated.div, { + base: { + height: 'fit-content', + left: 0, + position: 'absolute', + top: 0, + width: '100%', + }, +}); + +const getEstimatedViewerHeight = (embedCode: EmbedCodeEntity): number => { + switch (embedCode.service) { + case Service.BANDCAMP: { + return embedCode.src.includes('VideoEmbed') ? 380 : 120; + } + case Service.YOUTUBE: + case Service.SOUNDCLOUD: { + return 300; + } + default: { + return 0; + } + } +}; + +const calculateOffsetStyles = ( + embedCode: EmbedCodeEntity, + origin: DOMRect, +): CSSProperties => { + if (origin.top <= window.innerHeight / 2) { + return { top: origin.top + window.scrollY }; + } + return { + top: + origin.bottom + window.scrollY + 8 - getEstimatedViewerHeight(embedCode), + }; +}; + +export const EmbedViewer = (props: { + isOpen: boolean; + origin: DOMRect | null; + projectId: ProjectId; +}) => { + const { projectId, isOpen, origin } = props; + const embedCode = useQuery(api.projects.loadProjectEmbed, { projectId }); + const transitions = useTransition(isOpen && embedCode, { + from: { opacity: 1, scale: 0.88 }, + enter: { opacity: 1, scale: 1 }, + leave: { opacity: 0, scale: 0.88 }, + }); + + return transitions((styles, shouldRender) => { + if (shouldRender) { + return ( + + + + + + ); + } + + return null; + }); +}; diff --git a/src/feat/Projects/components/ProjectItem.tsx b/src/feat/Projects/ui/Preview.tsx similarity index 56% rename from src/feat/Projects/components/ProjectItem.tsx rename to src/feat/Projects/ui/Preview.tsx index 9df1dea..1ba15fa 100644 --- a/src/feat/Projects/components/ProjectItem.tsx +++ b/src/feat/Projects/ui/Preview.tsx @@ -1,17 +1,14 @@ import { useQuery } from 'convex/react'; -import { memo, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { type PropsWithChildren } from 'react'; -import { Box, Stack } from 'styled-system/jsx'; +import { Box } from 'styled-system/jsx'; import { api } from '~/convex/api'; import { debounce } from '~/lib/debounce'; import { useInteractions } from '~/lib/viewport'; -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'; +import type { ProjectId } from '../types'; const InnerPreview = (props: { projectId: ProjectId }) => { const { projectId } = props; @@ -75,7 +72,6 @@ const InnerPreview = (props: { projectId: ProjectId }) => { export const Preview = (props: PropsWithChildren<{ projectId: ProjectId }>) => { const { children, projectId } = props; const { hasHover } = useInteractions(); - if (!hasHover) { return children; } @@ -89,55 +85,3 @@ export const Preview = (props: PropsWithChildren<{ projectId: ProjectId }>) => { ); }; - -export const formatTimestamp = (ts: number) => - new Date(ts).toLocaleDateString('en-US'); - -export interface ProjectItemProps { - isPreviewEnabled: boolean; - isViewerEnabled: boolean; - item: ProjectEntity; - onSelect: (projectId: ProjectId) => void; -} - -export const ProjectItem = memo( - function ProjectItem(props) { - const { item, isPreviewEnabled, isViewerEnabled, onSelect } = props; - const { _id, title, url, category, publishedAt } = item; - - return ( - - {isPreviewEnabled ? ( - - { - e.preventDefault(); - onSelect(_id); - }, - } - : {})} - > - {title} - - - ) : ( - - {title} - - )} - - {category} - - {formatTimestamp(publishedAt)} - - - - ); - }, - (prev, next) => prev.item._id === next.item._id, -); diff --git a/src/feat/Projects/ui/ProjectItem.tsx b/src/feat/Projects/ui/ProjectItem.tsx new file mode 100644 index 0000000..c1adaf4 --- /dev/null +++ b/src/feat/Projects/ui/ProjectItem.tsx @@ -0,0 +1,75 @@ +import { animated, useSpring } from '@react-spring/web'; +import { memo, useRef } from 'react'; +import { Flex, Stack } from 'styled-system/jsx'; +import { Badge } from '~/ui/Badge'; +import { Link } from '~/ui/Link'; +import { Text } from '~/ui/Text'; +import { LinkIcon, WaveIcon } from '~/ui/icons'; +import type { ProjectEntity, ProjectId } from '../types'; +import { Preview } from './Preview'; + +export const formatTimestamp = (ts: number) => + new Date(ts).toLocaleDateString('en-US'); + +export interface ProjectItemProps { + isSelected: boolean; + isViewerEnabled: boolean; + item: ProjectEntity; + onSelect: (projectId: ProjectId, container: HTMLDivElement) => void; +} + +export const ProjectItem = memo( + function ProjectItem(props) { + const { isSelected, item, onSelect, isViewerEnabled } = props; + const { _id, title, url, category, publishedAt } = item; + const containerRef = useRef(null); + const styles = useSpring({ + from: { opacity: 1, marginBlock: '0rem' }, + to: isSelected + ? { opacity: 0, marginBlock: '0.68rem' } + : { opacity: 1, marginBlock: '0rem' }, + }); + + return ( + + + + { + e.preventDefault(); + onSelect(_id, containerRef.current); + }, + } + : {})} + > + {title} + + + + {category} + + {formatTimestamp(publishedAt)} + + {isViewerEnabled && ( + + {['sound', 'video'].includes(item.category) && item.embedId ? ( + + ) : ( + + )} + + )} + + + + ); + }, + (prev, next) => + prev.item._id === next.item._id && prev.isSelected === next.isSelected, +); 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} diff --git a/src/ui/MediaEmbed.tsx b/src/ui/MediaEmbed.tsx index 5901b8c..5379c8a 100644 --- a/src/ui/MediaEmbed.tsx +++ b/src/ui/MediaEmbed.tsx @@ -1,4 +1,14 @@ -import { Flex, styled, type HTMLStyledProps } from 'styled-system/jsx'; +import { useState } from 'react'; +import { Rand } from '@prtcl/plonk'; +import { useMetro } from '@prtcl/plonk-hooks'; +import { + Box, + Flex, + styled, + type FlexProps, + type HTMLStyledProps, +} from 'styled-system/jsx'; +import { Loader } from './Loader'; export enum Service { YOUTUBE = 'youtube', @@ -14,12 +24,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 +40,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 +57,55 @@ const getServiceProps = (service: Services): ServiceProps => { } }; +const Container = (props: FlexProps) => { + const { children, ...flexProps } = props; + return ( + + {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 useFauxLoader = () => { + 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); + } + }, + { time: 50 }, + ); + + return { isLoading }; +}; + export interface MediaEmbedProps { src: string; service: Services; @@ -53,27 +114,32 @@ export interface MediaEmbedProps { export const MediaEmbed = (props: MediaEmbedProps) => { const { src, service, title = 'Video' } = props; - const serviceProps = getServiceProps(service); + const { isLoading } = useFauxLoader(); + const serviceProps = getServiceProps(service, src); return ( - - +