diff --git a/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx b/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx index d64a48d3c6..c793d03f08 100644 --- a/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx +++ b/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx @@ -23,7 +23,12 @@ function HorizontalScrollComponent( const titleId = `horizontal-scroll-title-${id}`; const { ref, header } = useHorizontalScrollHeader({ ...scrollProps, - title: { ...scrollProps?.title, id: titleId }, + title: + scrollProps.title && + typeof scrollProps.title === 'object' && + 'copy' in scrollProps.title + ? { ...scrollProps.title, id: titleId } + : scrollProps.title, }); return ( diff --git a/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx b/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx index ba14c7f308..188959f46a 100644 --- a/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx +++ b/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx @@ -1,8 +1,9 @@ import type { MouseEventHandler, ReactElement, ReactNode } from 'react'; import React from 'react'; +import classNames from 'classnames'; import Link from '../utilities/Link'; import { Button } from '../buttons/Button'; -import { ButtonVariant } from '../buttons/common'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; import ConditionalWrapper from '../ConditionalWrapper'; import { ArrowIcon } from '../icons'; import { Typography, TypographyType } from '../typography/Typography'; @@ -15,7 +16,7 @@ export interface HorizontalScrollTitleProps { } export interface HorizontalScrollHeaderProps { - title: HorizontalScrollTitleProps; + title?: HorizontalScrollTitleProps | ReactNode; isAtEnd: boolean; isAtStart: boolean; onClickNext: MouseEventHandler; @@ -23,6 +24,8 @@ export interface HorizontalScrollHeaderProps { onClickSeeAll?: MouseEventHandler; linkToSeeAll?: string; canScroll: boolean; + className?: string; + buttonSize?: ButtonSize; } export const HorizontalScrollTitle = ({ @@ -50,10 +53,25 @@ export function HorizontalScrollHeader({ onClickSeeAll, linkToSeeAll, canScroll, + className, + buttonSize = ButtonSize.Medium, }: HorizontalScrollHeaderProps): ReactElement { + // Check if title is props object or custom ReactNode + const isCustomTitle = + title && typeof title === 'object' && !('copy' in title); + return ( -
- +
+ {isCustomTitle + ? title + : title && ( + + )} {canScroll && (
+ + )} +
+ )} + + {shouldShowReadme && ( +
+ +
+ )} +
+
+ + + ); +} diff --git a/packages/shared/src/features/profile/components/Activity.helpers.tsx b/packages/shared/src/features/profile/components/Activity.helpers.tsx new file mode 100644 index 0000000000..dd1fe0df04 --- /dev/null +++ b/packages/shared/src/features/profile/components/Activity.helpers.tsx @@ -0,0 +1,219 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { MyProfileEmptyScreen } from '../../../components/profile/MyProfileEmptyScreen'; +import { ProfileEmptyScreen } from '../../../components/profile/ProfileEmptyScreen'; +import { link } from '../../../lib/links'; +import { + OtherFeedPage, + generateQueryKey, + RequestKey, +} from '../../../lib/query'; +import type { FeedProps } from '../../../components/Feed'; +import { + AUTHOR_FEED_QUERY, + USER_UPVOTED_FEED_QUERY, +} from '../../../graphql/feed'; + +export type ActivityTab = { id: string; title: string; path: string }; + +export interface FeedData { + pages?: Array<{ + page?: { + edges?: Array; + }; + }>; +} + +export interface CommentsData { + page?: { + edges?: Array; + }; +} + +export enum ActivityTabIndex { + Posts = 0, + Replies = 1, + Upvoted = 2, +} + +export const activityTabs: ActivityTab[] = [ + { + id: 'posts', + title: 'Posts', + path: '/posts', + }, + { + id: 'replies', + title: 'Replies', + path: '/replies', + }, + { + id: 'upvoted', + title: 'Upvoted', + path: '/upvoted', + }, +]; + +export const COMMENT_CLASS_NAME = { + container: 'rounded-none border-0 border-b', + commentBox: { + container: 'relative border-0 rounded-none', + }, +} as const; + +export const MIN_ITEMS_FOR_SHOW_MORE = 3; +export const HORIZONTAL_FEED_CLASSES = + '[&_.grid]:!auto-cols-[17rem] [&_.grid]:gap-4'; +export const TAB_ITEMS = activityTabs.map((tab) => ({ label: tab.title })); + +export const ACTIVITY_QUERY_KEYS = { + posts: (userId: string) => ['author', userId] as const, + upvoted: (userId: string) => [OtherFeedPage.UserUpvoted, userId] as const, + comments: (userId: string) => + generateQueryKey(RequestKey.UserComments, null, userId), +} as const; + +export const getItemCount = ( + data: FeedData | CommentsData | undefined, + tabIndex: ActivityTabIndex, +): number => { + if (!data) { + return 0; + } + + switch (tabIndex) { + case ActivityTabIndex.Posts: + case ActivityTabIndex.Upvoted: + return (data as FeedData)?.pages?.[0]?.page?.edges?.length ?? 0; + case ActivityTabIndex.Replies: + return (data as CommentsData)?.page?.edges?.length ?? 0; + default: + return 0; + } +}; + +export const getUserPath = ( + username: string | undefined, + userId: string | undefined, + path: string, +): string => { + const userIdentifier = username || userId; + return `/${userIdentifier}${path}`; +}; + +export const renderEmptyScreen = ( + tabIndex: ActivityTabIndex, + isSameUser: boolean, + userName: string, +): ReactElement | null => { + if (isSameUser) { + switch (tabIndex) { + case ActivityTabIndex.Posts: + return ( + + ); + case ActivityTabIndex.Upvoted: + return ( + + ); + case ActivityTabIndex.Replies: + return ( + + ); + default: + return null; + } + } + + // Other user's profile + switch (tabIndex) { + case ActivityTabIndex.Posts: + return ( + + ); + case ActivityTabIndex.Upvoted: + return ( + + ); + case ActivityTabIndex.Replies: + return ( + + ); + default: + return null; + } +}; + +export const useActivityFeedProps = ( + userId: string, + isSameUser: boolean, + userName: string, +) => { + const postsFeedProps: FeedProps = useMemo( + () => ({ + feedName: OtherFeedPage.Author, + feedQueryKey: ACTIVITY_QUERY_KEYS.posts(userId), + query: AUTHOR_FEED_QUERY, + variables: { + userId, + }, + disableAds: true, + allowFetchMore: false, + pageSize: 10, + isHorizontal: true, + emptyScreen: renderEmptyScreen( + ActivityTabIndex.Posts, + isSameUser, + userName, + ), + }), + [userId, isSameUser, userName], + ); + + const upvotedFeedProps: FeedProps = useMemo( + () => ({ + feedName: OtherFeedPage.UserUpvoted, + feedQueryKey: ACTIVITY_QUERY_KEYS.upvoted(userId), + query: USER_UPVOTED_FEED_QUERY, + variables: { + userId, + }, + disableAds: true, + allowFetchMore: false, + pageSize: 10, + isHorizontal: true, + emptyScreen: renderEmptyScreen( + ActivityTabIndex.Upvoted, + isSameUser, + userName, + ), + }), + [userId, isSameUser, userName], + ); + + return { postsFeedProps, upvotedFeedProps }; +}; diff --git a/packages/shared/src/features/profile/components/Activity.tsx b/packages/shared/src/features/profile/components/Activity.tsx new file mode 100644 index 0000000000..bb7be2f684 --- /dev/null +++ b/packages/shared/src/features/profile/components/Activity.tsx @@ -0,0 +1,227 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useContext, useState, useMemo, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { PublicProfile } from '../../../lib/user'; +import { Button, ButtonVariant } from '../../../components/buttons/Button'; +import type { FeedProps } from '../../../components/Feed'; +import Feed from '../../../components/Feed'; +import AuthContext from '../../../contexts/AuthContext'; +import CommentFeed from '../../../components/CommentFeed'; +import { USER_COMMENTS_QUERY } from '../../../graphql/comments'; +import { Origin } from '../../../lib/log'; +import { useHorizontalScrollHeader } from '../../../components/HorizontalScroll/useHorizontalScrollHeader'; +import { + TypographyType, + Typography, +} from '../../../components/typography/Typography'; +import Link from '../../../components/utilities/Link'; +import FeedContext from '../../../contexts/FeedContext'; +import TabList from '../../../components/tabs/TabList'; +import { ButtonSize } from '../../../components/buttons/common'; +import type { FeedData, CommentsData } from './Activity.helpers'; +import { + ActivityTabIndex, + activityTabs, + COMMENT_CLASS_NAME, + MIN_ITEMS_FOR_SHOW_MORE, + HORIZONTAL_FEED_CLASSES, + TAB_ITEMS, + ACTIVITY_QUERY_KEYS, + getItemCount, + getUserPath, + renderEmptyScreen, + useActivityFeedProps, +} from './Activity.helpers'; + +type ActivityProps = { + user: PublicProfile; +}; + +interface ActivityHeaderProps { + selectedTab: string; + onTabClick: (label: string) => void; +} + +const ActivityHeader = ({ + selectedTab, + onTabClick, +}: ActivityHeaderProps): ReactElement => { + return ( +
+ + Activity + + +
+ ); +}; + +interface HorizontalFeedWithContextProps { + feedProps: FeedProps; + feedRef: RefObject; +} + +const HorizontalFeedWithContext = ({ + feedProps, + feedRef, +}: HorizontalFeedWithContextProps): ReactElement => { + const currentFeedSettings = useContext(FeedContext); + + const feedContextValue = useMemo(() => { + const numCards = 3; + + return { + ...currentFeedSettings, + numCards: { + eco: numCards, + roomy: numCards, + cozy: numCards, + }, + }; + }, [currentFeedSettings]); + + return ( + + } + /> + + ); +}; + +export const Activity = ({ user }: ActivityProps): ReactElement | null => { + const [selectedTab, setSelectedTab] = useState(activityTabs[0].title); + const { user: loggedUser } = useContext(AuthContext); + const isSameUser = user && loggedUser?.id === user.id; + const userId = user?.id; + + const selectedTabIndex = useMemo( + () => activityTabs.findIndex((tab) => tab.title === selectedTab), + [selectedTab], + ); + + const { postsFeedProps, upvotedFeedProps } = useActivityFeedProps( + userId, + isSameUser, + user?.name ?? 'User', + ); + + const { data: postsData } = useQuery({ + queryKey: ACTIVITY_QUERY_KEYS.posts(userId), + enabled: false, + }); + + const { data: upvotedData } = useQuery({ + queryKey: ACTIVITY_QUERY_KEYS.upvoted(userId), + enabled: false, + }); + + const { data: commentsData } = useQuery({ + queryKey: ACTIVITY_QUERY_KEYS.comments(userId), + enabled: false, + }); + + const shouldShowMoreButton = useMemo(() => { + let data: FeedData | CommentsData | undefined; + + if (selectedTabIndex === ActivityTabIndex.Posts) { + data = postsData; + } else if (selectedTabIndex === ActivityTabIndex.Replies) { + data = commentsData; + } else { + data = upvotedData; + } + + const itemCount = getItemCount(data, selectedTabIndex); + return itemCount > MIN_ITEMS_FOR_SHOW_MORE; + }, [selectedTabIndex, postsData, commentsData, upvotedData]); + + const handleTabClick = useCallback((label: string) => { + setSelectedTab(label); + }, []); + + const { ref, header: horizontalHeader } = useHorizontalScrollHeader({ + title: ( + + ), + className: '!items-end !m-0', + buttonSize: ButtonSize.Small, + }); + + const renderContent = () => { + switch (selectedTabIndex) { + case ActivityTabIndex.Posts: + return ( + + ); + case ActivityTabIndex.Replies: + return ( + + ); + case ActivityTabIndex.Upvoted: + return ( + + ); + default: + return null; + } + }; + + const renderHeader = () => { + if (selectedTabIndex !== ActivityTabIndex.Replies) { + return horizontalHeader; + } + + return ( +
+ +
+ ); + }; + + if (!userId) { + return null; + } + + return ( +
+ {renderHeader()} + {renderContent()} + {shouldShowMoreButton && ( + + + + )} +
+ ); +}; diff --git a/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx b/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx index 483e35bcc1..9c1c8981f3 100644 --- a/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx +++ b/packages/shared/src/features/profile/components/experience/UserExperiencesList.tsx @@ -47,7 +47,7 @@ export function UserExperienceList({ } return ( -
+
{title} diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 48bbd47506..e6dc7be5e2 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -31,3 +31,20 @@ .break-words-overflow { overflow-wrap: break-word; } + +/* Sidebar overlay mode on profile pages below 1360px */ +@media (max-width: 1359px) and (min-width: 1020px) { + /* Make sidebar overlay on top of content when expanded */ + body:has(.profile-page) nav[role="navigation"] { + position: fixed !important; + z-index: 100 !important; + height: 100vh !important; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15) !important; + } + + /* Remove sidebar padding from main content */ + body:has(.profile-page) main { + padding-left: 0 !important; + margin-left: 20px; + } +} diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index ff21bd67b4..7c523516a7 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -26,6 +26,7 @@ import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout'; import { getLayout as getMainLayout } from '../MainLayout'; import { getTemplatedTitle } from '../utils'; import { ProfileWidgets } from '../../../../shared/src/features/profile/components/ProfileWidgets/ProfileWidgets'; +import { useProfileSidebarCollapse } from '../../../hooks/useProfileSidebarCollapse'; const Custom404 = dynamic( () => import(/* webpackChunkName: "404" */ '../../../pages/404'), @@ -79,6 +80,9 @@ export default function ProfileLayout({ const { logEvent } = useLogContext(); const { referrerPost } = usePostReferrerContext(); + // Auto-collapse sidebar on small screens + useProfileSidebarCollapse(); + useEffect(() => { if (trackedView || !user) { return; @@ -107,12 +111,14 @@ export default function ProfileLayout({ } return ( -
+
-
{children}
-