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 (
-
- {
- 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 (
-
-
+
-
+ {isLoading && (
+
+
+
+ )}
+
);
};
diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx
index e040850..47ca407 100644
--- a/src/ui/icons.tsx
+++ b/src/ui/icons.tsx
@@ -1,6 +1,7 @@
import type { IconType } from 'react-icons';
import { FiChevronsLeft } from 'react-icons/fi';
-import { RxChevronLeft, RxCross2 } from 'react-icons/rx';
+import { PiWaves } from 'react-icons/pi';
+import { RxChevronLeft, RxCross2, RxLink1 } from 'react-icons/rx';
import { cva, type RecipeVariantProps } from 'styled-system/css';
import { styled, type HTMLStyledProps } from 'styled-system/jsx';
@@ -50,3 +51,5 @@ export type IconProps = HTMLStyledProps<'svg'> & IconVariantProps;
export const BackIcon = makeIcon(FiChevronsLeft);
export const ChevronLeftIcon = makeIcon(RxChevronLeft);
export const CloseIcon = makeIcon(RxCross2);
+export const LinkIcon = makeIcon(RxLink1);
+export const WaveIcon = makeIcon(PiWaves);