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 && (
{(onClickSeeAll || linkToSeeAll) && (
) => void;
onClickSeeAll?: MouseEventHandler;
linkToSeeAll?: string;
- title: HorizontalScrollTitleProps;
+ title?: HorizontalScrollTitleProps | ReactNode;
+ className?: string;
+ buttonSize?: ButtonSize;
}
export const useHorizontalScrollHeader = <
@@ -31,6 +34,8 @@ export const useHorizontalScrollHeader = <
onClickSeeAll,
linkToSeeAll,
title,
+ className,
+ buttonSize = ButtonSize.Medium,
}: UseHorizontalScrollHeaderProps): HorizontalScrollHeaderReturn => {
const ref = useRef(null);
// Calculate the width of elements and the number of visible cards
@@ -70,6 +75,8 @@ export const useHorizontalScrollHeader = <
onClickPrevious={onClickPrevious}
onClickSeeAll={onClickSeeAll}
linkToSeeAll={linkToSeeAll}
+ className={className}
+ buttonSize={buttonSize}
/>
);
diff --git a/packages/shared/src/features/profile/components/AboutMe.spec.tsx b/packages/shared/src/features/profile/components/AboutMe.spec.tsx
new file mode 100644
index 0000000000..f500884eb9
--- /dev/null
+++ b/packages/shared/src/features/profile/components/AboutMe.spec.tsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { PublicProfile } from '../../../lib/user';
+import { AboutMe } from './AboutMe';
+import { getLogContextStatic } from '../../../contexts/LogContext';
+
+const LogContext = getLogContextStatic();
+
+const mockLogEvent = jest.fn();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ // Mock window for client-side check
+ Object.defineProperty(window, 'innerWidth', {
+ writable: true,
+ configurable: true,
+ value: 1024,
+ });
+});
+
+const baseUser: PublicProfile = {
+ id: 'u1',
+ name: 'Test User',
+ username: 'testuser',
+ image: 'https://daily.dev/user.png',
+ permalink: 'https://daily.dev/testuser',
+ reputation: 100,
+ createdAt: '2020-01-01T00:00:00.000Z',
+ premium: false,
+};
+
+const userWithReadme: PublicProfile = {
+ ...baseUser,
+ readmeHtml: 'This is my awesome bio with some **markdown**!',
+};
+
+const userWithSocialLinks: PublicProfile = {
+ ...userWithReadme,
+ twitter: 'testuser',
+ github: 'testuser',
+ linkedin: 'https://linkedin.com/in/testuser',
+ portfolio: 'https://testuser.com',
+ youtube: 'testuser',
+ stackoverflow: '123456/testuser',
+ reddit: 'testuser',
+ roadmap: 'testuser',
+ codepen: 'testuser',
+ mastodon: 'https://mastodon.social/@testuser',
+ bluesky: 'testuser.bsky.social',
+ threads: 'testuser',
+};
+
+const renderComponent = (user: Partial = {}) => {
+ const mergedUser = { ...baseUser, ...user };
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ return render(
+
+
+
+
+ ,
+ );
+};
+
+describe('AboutMe', () => {
+ describe('Rendering', () => {
+ it('should not render when both readme and social links are not present', () => {
+ const { container } = renderComponent();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should render when readme is present', () => {
+ renderComponent(userWithReadme);
+ expect(screen.getByText('About me')).toBeInTheDocument();
+ expect(
+ screen.getByText('This is my awesome bio with some **markdown**!'),
+ ).toBeInTheDocument();
+ });
+
+ it('should render with social links when user has them', () => {
+ renderComponent(userWithSocialLinks);
+ expect(screen.getByTestId('social-link-github')).toBeInTheDocument();
+ expect(screen.getByTestId('social-link-linkedin')).toBeInTheDocument();
+ expect(screen.getByTestId('social-link-portfolio')).toBeInTheDocument();
+ });
+ });
+
+ describe('Social Links', () => {
+ it('should show only first 3 social links initially', () => {
+ renderComponent(userWithSocialLinks);
+ const allLinks = screen.getAllByTestId(/^social-link-/);
+ expect(allLinks.length).toBe(3);
+ });
+
+ it('should show "+N" button when more than 3 links', () => {
+ renderComponent(userWithSocialLinks);
+ const showAllButton = screen.getByTestId('show-all-links');
+ expect(showAllButton).toBeInTheDocument();
+ // 12 total links - 3 visible = 9 more
+ expect(showAllButton).toHaveTextContent('+9');
+ });
+
+ it('should show all links when "+N" button is clicked', async () => {
+ renderComponent(userWithSocialLinks);
+ const showAllButton = screen.getByTestId('show-all-links');
+ fireEvent.click(showAllButton);
+
+ await waitFor(() => {
+ const allLinks = screen.getAllByTestId(/^social-link-/);
+ expect(allLinks.length).toBe(12); // All 12 social links
+ });
+ });
+
+ it('should hide "+N" button after showing all links', async () => {
+ renderComponent(userWithSocialLinks);
+ const showAllButton = screen.getByTestId('show-all-links');
+ fireEvent.click(showAllButton);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('show-all-links')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Analytics', () => {
+ it('should log click event for social links', () => {
+ renderComponent(userWithSocialLinks);
+ const githubLink = screen.getByTestId('social-link-github');
+ fireEvent.click(githubLink);
+
+ expect(mockLogEvent).toHaveBeenCalledWith({
+ event_name: 'click',
+ target_type: 'social link',
+ target_id: 'github',
+ });
+ });
+ });
+});
diff --git a/packages/shared/src/features/profile/components/AboutMe.tsx b/packages/shared/src/features/profile/components/AboutMe.tsx
new file mode 100644
index 0000000000..01ef62d4da
--- /dev/null
+++ b/packages/shared/src/features/profile/components/AboutMe.tsx
@@ -0,0 +1,214 @@
+import type { ReactElement } from 'react';
+import React, { useMemo, useState } from 'react';
+import classNames from 'classnames';
+import type { PublicProfile } from '../../../lib/user';
+import Markdown from '../../../components/Markdown';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '../../../components/buttons/Button';
+import {
+ Typography,
+ TypographyType,
+ TypographyColor,
+} from '../../../components/typography/Typography';
+import {
+ BlueskyIcon,
+ CodePenIcon,
+ GitHubIcon,
+ LinkedInIcon,
+ LinkIcon,
+ MastodonIcon,
+ RedditIcon,
+ RoadmapIcon,
+ StackOverflowIcon,
+ ThreadsIcon,
+ TwitterIcon,
+ YoutubeIcon,
+} from '../../../components/icons';
+import { IconSize } from '../../../components/Icon';
+import { SimpleTooltip } from '../../../components/tooltips/SimpleTooltip';
+import { ExpandableContent } from '../../../components/ExpandableContent';
+import { useLogContext } from '../../../contexts/LogContext';
+import { combinedClicks } from '../../../lib/click';
+import { LogEvent, TargetType } from '../../../lib/log';
+import { anchorDefaultRel } from '../../../lib/strings';
+
+export interface AboutMeProps {
+ user: PublicProfile;
+ className?: string;
+}
+
+const MAX_VISIBLE_LINKS = 3;
+
+interface SocialLink {
+ id: string;
+ url: string;
+ icon: ReactElement;
+ label: string;
+}
+
+export function AboutMe({
+ user,
+ className,
+}: AboutMeProps): ReactElement | null {
+ const [showAllLinks, setShowAllLinks] = useState(false);
+ const readme = user?.readmeHtml;
+ const { logEvent } = useLogContext();
+
+ // Markdown is supported only in the client due to sanitization
+ const isClient = typeof window !== 'undefined';
+
+ const socialLinks = useMemo(() => {
+ return [
+ user.github && {
+ id: 'github',
+ url: `https://github.com/${user.github}`,
+ icon: ,
+ label: 'GitHub',
+ },
+ user.linkedin && {
+ id: 'linkedin',
+ url: user.linkedin,
+ icon: ,
+ label: 'LinkedIn',
+ },
+ user.portfolio && {
+ id: 'portfolio',
+ url: user.portfolio,
+ icon: ,
+ label: 'Portfolio',
+ },
+ user.twitter && {
+ id: 'twitter',
+ url: `https://x.com/${user.twitter}`,
+ icon: ,
+ label: 'Twitter',
+ },
+ user.youtube && {
+ id: 'youtube',
+ url: `https://youtube.com/@${user.youtube}`,
+ icon: ,
+ label: 'YouTube',
+ },
+ user.stackoverflow && {
+ id: 'stackoverflow',
+ url: `https://stackoverflow.com/users/${user.stackoverflow}`,
+ icon: ,
+ label: 'Stack Overflow',
+ },
+ user.reddit && {
+ id: 'reddit',
+ url: `https://reddit.com/user/${user.reddit}`,
+ icon: ,
+ label: 'Reddit',
+ },
+ user.roadmap && {
+ id: 'roadmap',
+ url: `https://roadmap.sh/u/${user.roadmap}`,
+ icon: ,
+ label: 'Roadmap.sh',
+ },
+ user.codepen && {
+ id: 'codepen',
+ url: `https://codepen.io/${user.codepen}`,
+ icon: ,
+ label: 'CodePen',
+ },
+ user.mastodon && {
+ id: 'mastodon',
+ url: user.mastodon,
+ icon: ,
+ label: 'Mastodon',
+ },
+ user.bluesky && {
+ id: 'bluesky',
+ url: `https://bsky.app/profile/${user.bluesky}`,
+ icon: ,
+ label: 'Bluesky',
+ },
+ user.threads && {
+ id: 'threads',
+ url: `https://threads.net/@${user.threads}`,
+ icon: ,
+ label: 'Threads',
+ },
+ ].filter(Boolean) as SocialLink[];
+ }, [user]);
+
+ const visibleLinks = showAllLinks
+ ? socialLinks
+ : socialLinks.slice(0, MAX_VISIBLE_LINKS);
+ const hasMoreLinks = socialLinks.length > MAX_VISIBLE_LINKS;
+
+ const shouldShowReadme = readme && isClient;
+ const shouldShowSocialLinks = socialLinks.length > 0;
+
+ if (!shouldShowReadme && !shouldShowSocialLinks) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ About me
+
+
+
+ {shouldShowSocialLinks && (
+
+ {visibleLinks.map((link) => (
+
+
+ ))}
+ {hasMoreLinks && !showAllLinks && (
+
+
+
+ )}
+
+ )}
+
+ {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}
-