From 031a6baf8fe175133020a545a1f59190456d8ae2 Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Tue, 28 Oct 2025 23:18:15 +0100 Subject: [PATCH 01/16] feat: about me section --- .../src/components/ExpandableContent.spec.tsx | 186 ++++++++++++++ .../src/components/ExpandableContent.tsx | 84 +++++++ .../src/components/profile/SocialChips.tsx | 162 ------------- .../components/AutofillProfileBanner.tsx | 2 +- .../components/ProfileWidgets/AboutMe.tsx | 226 ++++++++++++++++++ .../components/ExpandableContent.stories.tsx | 63 +++++ .../features/profile/AboutMe.stories.tsx | 175 ++++++++++++++ packages/webapp/pages/[userId]/index.tsx | 8 +- 8 files changed, 739 insertions(+), 167 deletions(-) create mode 100644 packages/shared/src/components/ExpandableContent.spec.tsx create mode 100644 packages/shared/src/components/ExpandableContent.tsx delete mode 100644 packages/shared/src/components/profile/SocialChips.tsx create mode 100644 packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx create mode 100644 packages/storybook/stories/components/ExpandableContent.stories.tsx create mode 100644 packages/storybook/stories/features/profile/AboutMe.stories.tsx diff --git a/packages/shared/src/components/ExpandableContent.spec.tsx b/packages/shared/src/components/ExpandableContent.spec.tsx new file mode 100644 index 0000000000..19f0697b34 --- /dev/null +++ b/packages/shared/src/components/ExpandableContent.spec.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { ExpandableContent } from './ExpandableContent'; +import clearAllMocks = jest.clearAllMocks; + +describe('ExpandableContent', () => { + const shortContent =
Short content that fits
; + const longContent = ( +
+

Long content paragraph 1

+

Long content paragraph 2

+

Long content paragraph 3

+

Long content paragraph 4

+

Long content paragraph 5

+

Long content paragraph 6

+

Long content paragraph 7

+

Long content paragraph 8

+

Long content paragraph 9

+

Long content paragraph 10

+
+ ); + + beforeEach(() => { + // Reset scrollHeight mock before each test + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 100; // Default value + }, + }); + }); + + afterEach(() => { + clearAllMocks(); + }); + + it('should render children content', () => { + render({shortContent}); + const elements = screen.getAllByText('Short content that fits'); + // Should render twice: once hidden for measurement, once visible + expect(elements).toHaveLength(2); + // At least one should be visible + expect(elements[1]).toBeVisible(); + }); + + it('should not show "See More" button when content is short', async () => { + // Mock scrollHeight to be less than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 200; // Less than default 320px + }, + }); + + render({shortContent}); + + // Wait a bit for useEffect to run + await waitFor( + () => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }, + { timeout: 200 }, + ); + }); + + it('should show "See More" button when content exceeds maxHeight', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; // More than default 320px + }, + }); + + render({longContent}); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); + + it('should expand content when "See More" button is clicked', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + + const seeMoreButton = screen.getByRole('button', { name: /see more/i }); + fireEvent.click(seeMoreButton); + + // Button should disappear after expansion + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('should show gradient overlay when content is collapsed', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + // Wait for See More button to appear, which indicates collapsed state + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); + + it('should hide "See More" button when content is expanded', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + + const seeMoreButton = screen.getByRole('button', { name: /see more/i }); + fireEvent.click(seeMoreButton); + + // Both button and gradient should be hidden after expansion + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('should apply custom maxHeight', async () => { + // Mock scrollHeight to exceed custom maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 200; // More than 150px + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared/src/components/ExpandableContent.tsx b/packages/shared/src/components/ExpandableContent.tsx new file mode 100644 index 0000000000..5a21891390 --- /dev/null +++ b/packages/shared/src/components/ExpandableContent.tsx @@ -0,0 +1,84 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from './buttons/Button'; +import { MoveToIcon } from './icons'; +import { IconSize } from './Icon'; + +export interface ExpandableContentProps { + children: ReactNode; + maxHeight?: number; // in pixels + className?: string; +} + +const DEFAULT_MAX_HEIGHT = 320; // pixels + +export function ExpandableContent({ + children, + maxHeight = DEFAULT_MAX_HEIGHT, + className, +}: ExpandableContentProps): ReactElement { + const [isExpanded, setIsExpanded] = useState(false); + const [showSeeMore, setShowSeeMore] = useState(false); + const contentRef = useRef(null); + const measureRef = useRef(null); + + useEffect(() => { + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setShowSeeMore(contentHeight > maxHeight); + } + }, [maxHeight]); + + return ( + <> + {/* Hidden div for measuring actual content height */} + + + {/* Visible content */} +
+ {children} + {!isExpanded && showSeeMore && ( +
+ )} +
+ + {showSeeMore && !isExpanded && ( +
+ +
+ )} + + ); +} diff --git a/packages/shared/src/components/profile/SocialChips.tsx b/packages/shared/src/components/profile/SocialChips.tsx deleted file mode 100644 index e390f3b37f..0000000000 --- a/packages/shared/src/components/profile/SocialChips.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import { - GitHubIcon, - TwitterIcon, - LinkIcon, - LinkedInIcon, - YoutubeIcon, - StackOverflowIcon, - RedditIcon, - RoadmapIcon, - MastodonIcon, - BlueskyIcon, - ThreadsIcon, - CodePenIcon, -} from '../icons'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { withHttps, withoutProtocol } from '../../lib/links'; -import { useLogContext } from '../../contexts/LogContext'; -import { combinedClicks } from '../../lib/click'; -import { LogEvent, TargetType } from '../../lib/log'; - -export interface SocialChipsProps { - links: { - github?: string; - twitter?: string; - portfolio?: string; - roadmap?: string; - threads?: string; - codepen?: string; - reddit?: string; - stackoverflow?: string; - youtube?: string; - linkedin?: string; - mastodon?: string; - bluesky?: string; - }; -} - -const handlers: Record< - keyof SocialChipsProps['links'], - { - icon: ReactElement; - href: (x: string) => string; - label: (x: string) => string; - } -> = { - github: { - icon: , - href: (x) => `https://github.com/${x}`, - label: (x) => `@${x}`, - }, - twitter: { - icon: , - href: (x) => `https://x.com/${x}`, - label: (x) => `@${x}`, - }, - portfolio: { - icon: , - href: (x) => withHttps(x), - // Strip protocol from url - label: (x) => withoutProtocol(x), - }, - linkedin: { - icon: , - href: (x) => `https://linkedin.com/in/${x}`, - label: (x) => x, - }, - youtube: { - icon: , - href: (x) => `https://youtube.com/@${x}`, - label: (x) => `@${x}`, - }, - stackoverflow: { - icon: , - href: (x) => `https://stackoverflow.com/users/${x}`, - label: (x) => x.split('/')[1] || x, - }, - reddit: { - icon: , - href: (x) => `https://reddit.com/user/${x}`, - label: (x) => `u/${x}`, - }, - roadmap: { - icon: , - href: (x) => `https://roadmap.sh/u/${x}`, - label: (x) => x, - }, - mastodon: { - icon: , - href: (x) => x, - label: (x) => withoutProtocol(x), - }, - bluesky: { - icon: , - href: (x) => `https://bsky.app/profile/${x}`, - label: (x) => x, - }, - threads: { - icon: , - href: (x) => `https://threads.net/@${x}`, - label: (x) => `@${x}`, - }, - codepen: { - icon: , - href: (x) => `https://codepen.io/${x}`, - label: (x) => x, - }, -}; -const order: (keyof SocialChipsProps['links'])[] = [ - 'github', - 'linkedin', - 'portfolio', - 'twitter', - 'youtube', - 'stackoverflow', - 'reddit', - 'roadmap', - 'codepen', - 'mastodon', - 'bluesky', - 'threads', -]; - -export function SocialChips({ links }: SocialChipsProps): ReactElement { - const { logEvent } = useLogContext(); - - const elements = order - .filter((key) => !!links[key]) - .map((key) => ( - - )); - - if (!elements.length) { - return <>; - } - - return ( -
- {elements} -
- ); -} diff --git a/packages/shared/src/features/profile/components/AutofillProfileBanner.tsx b/packages/shared/src/features/profile/components/AutofillProfileBanner.tsx index 54bbb70794..ca3950a62c 100644 --- a/packages/shared/src/features/profile/components/AutofillProfileBanner.tsx +++ b/packages/shared/src/features/profile/components/AutofillProfileBanner.tsx @@ -75,7 +75,7 @@ export function AutofillProfileBanner({ return (
{input}
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx new file mode 100644 index 0000000000..2512083ec2 --- /dev/null +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx @@ -0,0 +1,226 @@ +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, + CopyIcon, + GitHubIcon, + LinkedInIcon, + LinkIcon, + MastodonIcon, + RedditIcon, + RoadmapIcon, + StackOverflowIcon, + ThreadsIcon, + TwitterIcon, + YoutubeIcon, +} from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import { useCopyLink } from '../../../../hooks/useCopy'; +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'; + +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 [, copyLink] = useCopyLink(); + const { logEvent } = useLogContext(); + + // Markdown is supported only in the client due to sanitization + const isClient = typeof window !== 'undefined'; + + // Memoize social links to avoid recalculating on every render + 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; + + if (!readme || !isClient) { + return null; + } + + return ( +
+ +
+ + About me + + +
+
+ + + + )} +
+
+ +
+
+
+
+
+ ); +} diff --git a/packages/storybook/stories/components/ExpandableContent.stories.tsx b/packages/storybook/stories/components/ExpandableContent.stories.tsx new file mode 100644 index 0000000000..170fa30afb --- /dev/null +++ b/packages/storybook/stories/components/ExpandableContent.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { ExpandableContent } from '@dailydotdev/shared/src/components/ExpandableContent'; + +const meta: Meta = { + title: 'Components/ExpandableContent', + component: ExpandableContent, + tags: ['autodocs'], + argTypes: { + maxHeight: { + control: { type: 'number', min: 100, max: 800, step: 10 }, + description: 'Maximum height in pixels before showing "See More" button', + }, + className: { + control: 'text', + description: 'Additional CSS classes', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const ContentWithImages = () => ( +
+

Content with Images

+

+ This content includes images that should be properly handled by the + component's height detection. +

+ Random placeholder +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. The image above + should be included in the height calculation to properly show or hide the + "See More" button. +

+ Another placeholder +

+ More content below the second image to ensure the total height exceeds the + maximum and triggers the expandable behavior. +

+
+); + +export const WithImages: Story = { + args: { + maxHeight: 320, + }, + render: (args) => ( + + + + ), +}; diff --git a/packages/storybook/stories/features/profile/AboutMe.stories.tsx b/packages/storybook/stories/features/profile/AboutMe.stories.tsx new file mode 100644 index 0000000000..3b7640d732 --- /dev/null +++ b/packages/storybook/stories/features/profile/AboutMe.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AboutMe } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/AboutMe'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { fn } from 'storybook/test'; + +const meta: Meta = { + title: 'Features/Profile/AboutMe', + component: AboutMe, + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); + + const LogContext = getLogContextStatic(); + + const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + }; + + return ( + + + +
+ +
+
+
+
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +const shortContent = ` +# Here to break production + +Yes I do love to break production ๐Ÿ™ˆ + +I'm a passionate developer who enjoys learning new technologies and building great products. +`; + +const longContent = ` +# Here to break production + +Yes I do love to break production ๐Ÿ™ˆ + +I'm a passionate developer who enjoys learning new technologies and building great products. I have over 10 years of experience in software development, working with various technologies and frameworks. + +## My Journey + +Started my journey as a junior developer and worked my way up through various roles. I've worked on: + +- Large-scale web applications +- Mobile apps +- Cloud infrastructure +- DevOps and CI/CD pipelines + +## Technologies I Love + +I'm particularly interested in: + +- React and Next.js +- TypeScript +- Node.js +- GraphQL +- PostgreSQL +- Docker and Kubernetes + +![Development Setup](https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=800&h=400&fit=crop) + +## What Drives Me + +I believe in writing clean, maintainable code and sharing knowledge with the community. When I'm not coding, you can find me: + +- Contributing to open source projects +- Writing technical blog posts +- Attending tech conferences +- Mentoring junior developers + +Feel free to reach out if you want to collaborate on something exciting! +`; + +export const Default: Story = { + args: { + user: { + id: '1', + username: 'testuser', + name: 'Test User', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + readmeHtml: shortContent, + }, + }, +}; + +export const LongContent: Story = { + args: { + user: { + id: '1', + username: 'testuser', + name: 'Test User', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + readmeHtml: longContent, + }, + }, +}; + +export const EmptyStateOwnProfile: Story = { + args: { + user: { + id: '1', + username: 'testuser', + name: 'Test User', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + readmeHtml: undefined, + }, + }, +}; + +export const EmptyStateOtherProfile: Story = { + args: { + user: { + id: '2', + username: 'otheruser', + name: 'Other User', + image: + 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + readmeHtml: undefined, + }, + }, +}; diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index 5ec5d499fc..9269e648ed 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; -import { Readme } from '@dailydotdev/shared/src/components/profile/Readme'; +import { AboutMe } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/AboutMe'; import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile'; import { useActions, useJoinReferral } from '@dailydotdev/shared/src/hooks'; import { NextSeo } from 'next-seo'; @@ -72,18 +72,18 @@ const ProfilePage = ({ )}
-
+
{shouldShowBanner && ( )} - + {isUserSame && ( )} -
+
Date: Wed, 29 Oct 2025 09:38:18 +0100 Subject: [PATCH 02/16] fix: add tests and remove unused files --- .../shared/src/components/profile/Readme.tsx | 122 ------------ .../ProfileWidgets/AboutMe.spec.tsx | 169 +++++++++++++++++ .../components/ProfileWidgets/AboutMe.tsx | 110 +++++------ .../src/hooks/profile/useProfileReadme.ts | 74 -------- .../features/profile/AboutMe.stories.tsx | 175 ------------------ .../webapp/__tests__/ProfileIndexPage.tsx | 18 +- 6 files changed, 234 insertions(+), 434 deletions(-) delete mode 100644 packages/shared/src/components/profile/Readme.tsx create mode 100644 packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.spec.tsx delete mode 100644 packages/shared/src/hooks/profile/useProfileReadme.ts delete mode 100644 packages/storybook/stories/features/profile/AboutMe.stories.tsx diff --git a/packages/shared/src/components/profile/Readme.tsx b/packages/shared/src/components/profile/Readme.tsx deleted file mode 100644 index 4c6860f57f..0000000000 --- a/packages/shared/src/components/profile/Readme.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { FormEventHandler, ReactElement } from 'react'; -import React, { useContext } from 'react'; -import type { PublicProfile } from '../../lib/user'; -import AuthContext from '../../contexts/AuthContext'; -import { MyProfileEmptyScreen } from './MyProfileEmptyScreen'; -import Markdown from '../Markdown'; -import MarkdownInput from '../fields/MarkdownInput'; -import { formToJson } from '../../lib/form'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { useProfileReadme } from '../../hooks/profile/useProfileReadme'; - -export interface ReadmeProps { - user: PublicProfile; -} - -function ReadonlyReadme({ - isSameUser, - readme, - onClick, -}: { - isSameUser: boolean; - readme?: string; - onClick: () => unknown; -}): ReactElement { - // Markdown is supported only in the client due to sanitization - const isClient = typeof window !== 'undefined'; - - if (!isClient) { - return <>; - } - - return ( - <> - {isSameUser && ( - - )} - - - ); -} - -function EditableReadme({ - readme, - updateReadme, - isLoading, -}: { - readme: string; - updateReadme: (content: string) => unknown; - isLoading: boolean; -}): ReactElement { - const onSubmitForm: FormEventHandler = (e) => { - e.preventDefault(); - const { content } = formToJson<{ content: string }>(e.currentTarget); - return updateReadme(content); - }; - - return ( -
- updateReadme(e.currentTarget.value)} - isLoading={isLoading} - /> - - ); -} - -export function Readme({ user }: ReadmeProps): ReactElement { - const { user: loggedUser } = useContext(AuthContext); - const isSameUser = loggedUser?.id === user.id; - const readme = user?.readmeHtml; - - const { - readme: mdReadme, - updateReadme, - isLoadingReadme, - editMode, - setEditMode, - submitting, - } = useProfileReadme(user); - - if (editMode) { - return ( - - ); - } - if (!readme && isSameUser) { - return ( - setEditMode(true) }} - /> - ); - } - return ( - setEditMode(true)} - readme={readme} - /> - ); -} diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.spec.tsx new file mode 100644 index 0000000000..d3b4604221 --- /dev/null +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.spec.tsx @@ -0,0 +1,169 @@ +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 mockCopyLink = jest.fn(); +const mockLogEvent = jest.fn(); + +// Mock useCopyLink hook +jest.mock('../../../../hooks/useCopy', () => ({ + useCopyLink: () => [jest.fn(), mockCopyLink], +})); + +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.getAllByText('About me')[0]).toBeInTheDocument(); + expect( + screen.getAllByText( + 'This is my awesome bio with some **markdown**!', + )[0], + ).toBeInTheDocument(); + }); + + it('should render with social links when user has them', () => { + renderComponent(userWithSocialLinks); + expect( + screen.getAllByTestId('social-link-github')[0], + ).toBeInTheDocument(); + expect( + screen.getAllByTestId('social-link-linkedin')[0], + ).toBeInTheDocument(); + expect( + screen.getAllByTestId('social-link-portfolio')[0], + ).toBeInTheDocument(); + }); + }); + + describe('Social Links', () => { + it('should show only first 3 social links initially', () => { + renderComponent(userWithSocialLinks); + // Each link appears twice (measurement + visible), so we check the first half + const allLinks = screen.getAllByTestId(/^social-link-/); + const visibleLinks = allLinks.slice(0, allLinks.length / 2); + expect(visibleLinks.length).toBe(3); + }); + + it('should show "+N" button when more than 3 links', () => { + renderComponent(userWithSocialLinks); + const showAllButtons = screen.getAllByTestId('show-all-links'); + expect(showAllButtons[0]).toBeInTheDocument(); + // 12 total links - 3 visible = 9 more + expect(showAllButtons[0]).toHaveTextContent('+9'); + }); + + it('should show all links when "+N" button is clicked', async () => { + renderComponent(userWithSocialLinks); + const showAllButton = screen.getAllByTestId('show-all-links')[0]; + fireEvent.click(showAllButton); + + await waitFor(() => { + const allLinks = screen.getAllByTestId(/^social-link-/); + const visibleLinks = allLinks.slice(0, allLinks.length / 2); + expect(visibleLinks.length).toBe(12); // All 12 social links + }); + }); + + it('should hide "+N" button after showing all links', async () => { + renderComponent(userWithSocialLinks); + const showAllButton = screen.getAllByTestId('show-all-links')[0]; + 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.getAllByTestId('social-link-github')[0]; + 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/ProfileWidgets/AboutMe.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx index 2512083ec2..29fbb20180 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AboutMe.tsx @@ -16,7 +16,6 @@ import { import { BlueskyIcon, CodePenIcon, - CopyIcon, GitHubIcon, LinkedInIcon, LinkIcon, @@ -29,12 +28,12 @@ import { YoutubeIcon, } from '../../../../components/icons'; import { IconSize } from '../../../../components/Icon'; -import { useCopyLink } from '../../../../hooks/useCopy'; 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; @@ -56,13 +55,11 @@ export function AboutMe({ }: AboutMeProps): ReactElement | null { const [showAllLinks, setShowAllLinks] = useState(false); const readme = user?.readmeHtml; - const [, copyLink] = useCopyLink(); const { logEvent } = useLogContext(); // Markdown is supported only in the client due to sanitization const isClient = typeof window !== 'undefined'; - // Memoize social links to avoid recalculating on every render const socialLinks = useMemo(() => { return [ user.github && { @@ -145,7 +142,10 @@ export function AboutMe({ : socialLinks.slice(0, MAX_VISIBLE_LINKS); const hasMoreLinks = socialLinks.length > MAX_VISIBLE_LINKS; - if (!readme || !isClient) { + const shouldShowReadme = readme && isClient; + const shouldShowSocialLinks = socialLinks.length > 0; + + if (!shouldShowReadme && !shouldShowSocialLinks) { return null; } @@ -162,62 +162,50 @@ export function AboutMe({
-
- - - - )} -
-
- -
+ {shouldShowSocialLinks && ( +
+ {visibleLinks.map((link) => ( + + + + )} +
+ )} + + {shouldShowReadme && ( +
+ +
+ )}
diff --git a/packages/shared/src/hooks/profile/useProfileReadme.ts b/packages/shared/src/hooks/profile/useProfileReadme.ts deleted file mode 100644 index 6fee85d688..0000000000 --- a/packages/shared/src/hooks/profile/useProfileReadme.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { ClientError } from 'graphql-request'; -import { useState } from 'react'; -import { generateQueryKey, RequestKey } from '../../lib/query'; -import { UPDATE_README_MUTATION, USER_README_QUERY } from '../../graphql/users'; -import type { PublicProfile } from '../../lib/user'; -import { useToastNotification } from '../useToastNotification'; -import { gqlClient } from '../../graphql/common'; - -export type UseProfileReadmeRet = { - readme?: string; - isLoadingReadme: boolean; - editMode: boolean; - setEditMode: (editMode: boolean) => void; - updateReadme: (content: string) => Promise; - submitting: boolean; -}; - -export function useProfileReadme(user: PublicProfile): UseProfileReadmeRet { - const { displayToast } = useToastNotification(); - const client = useQueryClient(); - const [editMode, setEditMode] = useState(false); - - const queryKey = generateQueryKey(RequestKey.Readme, user); - const { data: remoteReadme, isLoading } = useQuery<{ - user: { readme: string }; - }>({ - queryKey, - queryFn: () => - gqlClient.request(USER_README_QUERY, { - id: user.id, - }), - enabled: editMode, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - }); - - const { mutateAsync: updateReadme, isPending: submitting } = useMutation< - { updateReadme: { readmeHtml: string } }, - unknown, - string - >({ - mutationFn: (content) => - gqlClient.request(UPDATE_README_MUTATION, { content }), - - onSuccess: async () => { - setEditMode(false); - await client.invalidateQueries({ queryKey }); - await client.invalidateQueries({ - queryKey: generateQueryKey(RequestKey.Profile, user), - }); - }, - - onError: (err) => { - const clientError = err as ClientError; - const message = clientError?.response?.errors?.[0]?.message; - if (!message) { - return; - } - - displayToast(message); - }, - }); - - return { - readme: remoteReadme?.user.readme, - isLoadingReadme: isLoading, - editMode, - setEditMode, - updateReadme, - submitting, - }; -} diff --git a/packages/storybook/stories/features/profile/AboutMe.stories.tsx b/packages/storybook/stories/features/profile/AboutMe.stories.tsx deleted file mode 100644 index 3b7640d732..0000000000 --- a/packages/storybook/stories/features/profile/AboutMe.stories.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AboutMe } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/AboutMe'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import { fn } from 'storybook/test'; - -const meta: Meta = { - title: 'Features/Profile/AboutMe', - component: AboutMe, - decorators: [ - (Story) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Infinity, - }, - }, - }); - - const LogContext = getLogContextStatic(); - - const mockUser = { - id: '1', - name: 'Test User', - username: 'testuser', - email: 'test@example.com', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - providers: ['google'], - }; - - return ( - - - -
- -
-
-
-
- ); - }, - ], -}; - -export default meta; - -type Story = StoryObj; - -const shortContent = ` -# Here to break production - -Yes I do love to break production ๐Ÿ™ˆ - -I'm a passionate developer who enjoys learning new technologies and building great products. -`; - -const longContent = ` -# Here to break production - -Yes I do love to break production ๐Ÿ™ˆ - -I'm a passionate developer who enjoys learning new technologies and building great products. I have over 10 years of experience in software development, working with various technologies and frameworks. - -## My Journey - -Started my journey as a junior developer and worked my way up through various roles. I've worked on: - -- Large-scale web applications -- Mobile apps -- Cloud infrastructure -- DevOps and CI/CD pipelines - -## Technologies I Love - -I'm particularly interested in: - -- React and Next.js -- TypeScript -- Node.js -- GraphQL -- PostgreSQL -- Docker and Kubernetes - -![Development Setup](https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=800&h=400&fit=crop) - -## What Drives Me - -I believe in writing clean, maintainable code and sharing knowledge with the community. When I'm not coding, you can find me: - -- Contributing to open source projects -- Writing technical blog posts -- Attending tech conferences -- Mentoring junior developers - -Feel free to reach out if you want to collaborate on something exciting! -`; - -export const Default: Story = { - args: { - user: { - id: '1', - username: 'testuser', - name: 'Test User', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - readmeHtml: shortContent, - }, - }, -}; - -export const LongContent: Story = { - args: { - user: { - id: '1', - username: 'testuser', - name: 'Test User', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - readmeHtml: longContent, - }, - }, -}; - -export const EmptyStateOwnProfile: Story = { - args: { - user: { - id: '1', - username: 'testuser', - name: 'Test User', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - readmeHtml: undefined, - }, - }, -}; - -export const EmptyStateOtherProfile: Story = { - args: { - user: { - id: '2', - username: 'otheruser', - name: 'Other User', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - readmeHtml: undefined, - }, - }, -}; diff --git a/packages/webapp/__tests__/ProfileIndexPage.tsx b/packages/webapp/__tests__/ProfileIndexPage.tsx index ceafc568c6..ce1b929fde 100644 --- a/packages/webapp/__tests__/ProfileIndexPage.tsx +++ b/packages/webapp/__tests__/ProfileIndexPage.tsx @@ -125,8 +125,22 @@ it('should show the top reading tags of the user', async () => { await screen.findByText('C#'); }); -it('should show the readme of the user', async () => { +it('should show the about me section with readme of the user', async () => { renderComponent(); await waitForNock(); - expect(await screen.findByText('This is my readme')).toBeInTheDocument(); + const aboutMeHeadings = await screen.findAllByText('About me'); + expect(aboutMeHeadings.length).toBeGreaterThan(0); + const readmeContent = await screen.findAllByText('This is my readme'); + expect(readmeContent.length).toBeGreaterThan(0); +}); + +it('should show social links in about me section', async () => { + renderComponent(); + await waitForNock(); + const twitterLinks = await screen.findAllByTestId('social-link-twitter'); + expect(twitterLinks.length).toBeGreaterThan(0); + expect(twitterLinks[0]).toHaveAttribute('href', 'https://x.com/dailydotdev'); + const githubLinks = await screen.findAllByTestId('social-link-github'); + expect(githubLinks.length).toBeGreaterThan(0); + expect(githubLinks[0]).toHaveAttribute('href', 'https://github.com/dailydotdev'); }); From 23a33c670511ee18b4737ddb31c132a57479ac31 Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Wed, 29 Oct 2025 09:39:32 +0100 Subject: [PATCH 03/16] fix: lint --- packages/webapp/__tests__/ProfileIndexPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/webapp/__tests__/ProfileIndexPage.tsx b/packages/webapp/__tests__/ProfileIndexPage.tsx index ce1b929fde..2f87a8db41 100644 --- a/packages/webapp/__tests__/ProfileIndexPage.tsx +++ b/packages/webapp/__tests__/ProfileIndexPage.tsx @@ -142,5 +142,8 @@ it('should show social links in about me section', async () => { expect(twitterLinks[0]).toHaveAttribute('href', 'https://x.com/dailydotdev'); const githubLinks = await screen.findAllByTestId('social-link-github'); expect(githubLinks.length).toBeGreaterThan(0); - expect(githubLinks[0]).toHaveAttribute('href', 'https://github.com/dailydotdev'); + expect(githubLinks[0]).toHaveAttribute( + 'href', + 'https://github.com/dailydotdev', + ); }); From 84944c3ad8c48f4b5bc17fb9b65ccbceab783301 Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Wed, 29 Oct 2025 10:23:19 +0100 Subject: [PATCH 04/16] fix: expand --- packages/shared/src/components/ExpandableContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/ExpandableContent.tsx b/packages/shared/src/components/ExpandableContent.tsx index 5a21891390..ba46778e0c 100644 --- a/packages/shared/src/components/ExpandableContent.tsx +++ b/packages/shared/src/components/ExpandableContent.tsx @@ -33,7 +33,7 @@ export function ExpandableContent({ const contentHeight = measureRef.current.scrollHeight; setShowSeeMore(contentHeight > maxHeight); } - }, [maxHeight]); + }, [maxHeight, children]); return ( <> From 2ff68a1de073e8b567f4a0da318b1c906f2db9b8 Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Wed, 29 Oct 2025 11:10:29 +0100 Subject: [PATCH 05/16] fix: handle async images --- .../src/components/ExpandableContent.spec.tsx | 8 +-- .../src/components/ExpandableContent.tsx | 34 ++++++----- .../ProfileWidgets/AboutMe.spec.tsx | 37 +++++------- .../components/ExpandableContent.stories.tsx | 57 ++++++++++++++++++- 4 files changed, 92 insertions(+), 44 deletions(-) diff --git a/packages/shared/src/components/ExpandableContent.spec.tsx b/packages/shared/src/components/ExpandableContent.spec.tsx index 19f0697b34..425765a9bc 100644 --- a/packages/shared/src/components/ExpandableContent.spec.tsx +++ b/packages/shared/src/components/ExpandableContent.spec.tsx @@ -36,11 +36,9 @@ describe('ExpandableContent', () => { it('should render children content', () => { render({shortContent}); - const elements = screen.getAllByText('Short content that fits'); - // Should render twice: once hidden for measurement, once visible - expect(elements).toHaveLength(2); - // At least one should be visible - expect(elements[1]).toBeVisible(); + const element = screen.getByText('Short content that fits'); + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); }); it('should not show "See More" button when content is short', async () => { diff --git a/packages/shared/src/components/ExpandableContent.tsx b/packages/shared/src/components/ExpandableContent.tsx index ba46778e0c..81f68415b6 100644 --- a/packages/shared/src/components/ExpandableContent.tsx +++ b/packages/shared/src/components/ExpandableContent.tsx @@ -26,27 +26,35 @@ export function ExpandableContent({ const [isExpanded, setIsExpanded] = useState(false); const [showSeeMore, setShowSeeMore] = useState(false); const contentRef = useRef(null); - const measureRef = useRef(null); useEffect(() => { - if (measureRef.current) { - const contentHeight = measureRef.current.scrollHeight; + const element = contentRef.current; + if (!element) { + return undefined; + } + + const checkHeight = () => { + const contentHeight = element.scrollHeight; setShowSeeMore(contentHeight > maxHeight); + }; + + // Initial check + checkHeight(); + + // Only use ResizeObserver if there are images (for async loading) + const hasImages = element.querySelector('img') !== null; + if (!hasImages) { + return undefined; } + + const resizeObserver = new ResizeObserver(checkHeight); + resizeObserver.observe(element); + + return () => resizeObserver.disconnect(); }, [maxHeight, children]); return ( <> - {/* Hidden div for measuring actual content height */} - - - {/* Visible content */}
{ it('should render when readme is present', () => { renderComponent(userWithReadme); - expect(screen.getAllByText('About me')[0]).toBeInTheDocument(); + expect(screen.getByText('About me')).toBeInTheDocument(); expect( - screen.getAllByText( - 'This is my awesome bio with some **markdown**!', - )[0], + 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.getAllByTestId('social-link-github')[0], - ).toBeInTheDocument(); - expect( - screen.getAllByTestId('social-link-linkedin')[0], - ).toBeInTheDocument(); - expect( - screen.getAllByTestId('social-link-portfolio')[0], - ).toBeInTheDocument(); + 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); - // Each link appears twice (measurement + visible), so we check the first half const allLinks = screen.getAllByTestId(/^social-link-/); - const visibleLinks = allLinks.slice(0, allLinks.length / 2); - expect(visibleLinks.length).toBe(3); + expect(allLinks.length).toBe(3); }); it('should show "+N" button when more than 3 links', () => { renderComponent(userWithSocialLinks); - const showAllButtons = screen.getAllByTestId('show-all-links'); - expect(showAllButtons[0]).toBeInTheDocument(); + const showAllButton = screen.getByTestId('show-all-links'); + expect(showAllButton).toBeInTheDocument(); // 12 total links - 3 visible = 9 more - expect(showAllButtons[0]).toHaveTextContent('+9'); + expect(showAllButton).toHaveTextContent('+9'); }); it('should show all links when "+N" button is clicked', async () => { renderComponent(userWithSocialLinks); - const showAllButton = screen.getAllByTestId('show-all-links')[0]; + const showAllButton = screen.getByTestId('show-all-links'); fireEvent.click(showAllButton); await waitFor(() => { const allLinks = screen.getAllByTestId(/^social-link-/); - const visibleLinks = allLinks.slice(0, allLinks.length / 2); - expect(visibleLinks.length).toBe(12); // All 12 social links + expect(allLinks.length).toBe(12); // All 12 social links }); }); it('should hide "+N" button after showing all links', async () => { renderComponent(userWithSocialLinks); - const showAllButton = screen.getAllByTestId('show-all-links')[0]; + const showAllButton = screen.getByTestId('show-all-links'); fireEvent.click(showAllButton); await waitFor(() => { @@ -156,7 +145,7 @@ describe('AboutMe', () => { describe('Analytics', () => { it('should log click event for social links', () => { renderComponent(userWithSocialLinks); - const githubLink = screen.getAllByTestId('social-link-github')[0]; + const githubLink = screen.getByTestId('social-link-github'); fireEvent.click(githubLink); expect(mockLogEvent).toHaveBeenCalledWith({ diff --git a/packages/storybook/stories/components/ExpandableContent.stories.tsx b/packages/storybook/stories/components/ExpandableContent.stories.tsx index 170fa30afb..b9bbc3b3ff 100644 --- a/packages/storybook/stories/components/ExpandableContent.stories.tsx +++ b/packages/storybook/stories/components/ExpandableContent.stories.tsx @@ -1,5 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; +import type { ExpandableContentProps } from '@dailydotdev/shared/src/components/ExpandableContent'; import { ExpandableContent } from '@dailydotdev/shared/src/components/ExpandableContent'; const meta: Meta = { @@ -22,6 +23,58 @@ export default meta; type Story = StoryObj; +const LongTextContent = () => ( +
+

Long Text Content

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium + doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore + veritatis et quasi architecto beatae vitae dicta sunt explicabo. +

+

+ Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, + sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. +

+

+ Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, + adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et + dolore magnam aliquam quaerat voluptatem. +

+

+ Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit + laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure + reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur. +

+

+ At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis + praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias + excepturi sint occaecati cupiditate non provident. +

+
+); + +export const Default: Story = { + args: { + maxHeight: 320, + }, + render: (args: ExpandableContentProps) => ( + + + + ), +}; + const ContentWithImages = () => (

Content with Images

@@ -55,7 +108,7 @@ export const WithImages: Story = { args: { maxHeight: 320, }, - render: (args) => ( + render: (args: ExpandableContentProps) => ( From e7e1c1e5ad9637cf4b704e45798a80941d5a06f8 Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Wed, 29 Oct 2025 11:25:28 +0100 Subject: [PATCH 06/16] fix: show see more in initial load --- .../shared/src/components/ExpandableContent.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/ExpandableContent.tsx b/packages/shared/src/components/ExpandableContent.tsx index 81f68415b6..518a7385e2 100644 --- a/packages/shared/src/components/ExpandableContent.tsx +++ b/packages/shared/src/components/ExpandableContent.tsx @@ -38,19 +38,27 @@ export function ExpandableContent({ setShowSeeMore(contentHeight > maxHeight); }; - // Initial check - checkHeight(); + // Wait for browser to complete layout before checking height + // Using double RAF ensures the layout is fully calculated + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + checkHeight(); + }); + }); // Only use ResizeObserver if there are images (for async loading) const hasImages = element.querySelector('img') !== null; if (!hasImages) { - return undefined; + return () => cancelAnimationFrame(rafId); } const resizeObserver = new ResizeObserver(checkHeight); resizeObserver.observe(element); - return () => resizeObserver.disconnect(); + return () => { + cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + }; }, [maxHeight, children]); return ( From b1cf1ab00c13de789692e42d43fd563a92a514fe Mon Sep 17 00:00:00 2001 From: NensiDosari Date: Wed, 29 Oct 2025 11:46:24 +0100 Subject: [PATCH 07/16] fix: border --- packages/webapp/pages/[userId]/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index 9269e648ed..8562c42f96 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -79,11 +79,12 @@ const ProfilePage = ({ isLoading={status === 'pending'} /> )} + {!shouldShowBanner &&
} {isUserSame && ( )} -
+
Date: Wed, 29 Oct 2025 12:24:28 +0100 Subject: [PATCH 08/16] fix: showing squads in mobile --- .../components/ProfileWidgets/ActiveOrRecomendedSquads.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ActiveOrRecomendedSquads.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ActiveOrRecomendedSquads.tsx index c67e0f77a6..c7444ba3c2 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ActiveOrRecomendedSquads.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ActiveOrRecomendedSquads.tsx @@ -100,7 +100,7 @@ export const ActiveOrRecomendedSquads = ( > {heading} -
    +
      {squads.map((squad) => ( Date: Thu, 30 Oct 2025 12:59:30 +0100 Subject: [PATCH 09/16] feat: new activity section + fix profile betwen laptop tablet transition --- .../components/ProfileWidgets/Activity.tsx | 262 ++++++++++++++++++ packages/shared/src/styles/utilities.css | 17 ++ .../layouts/ProfileLayout/index.tsx | 10 +- .../webapp/hooks/useProfileSidebarCollapse.ts | 36 +++ packages/webapp/pages/[userId]/index.tsx | 2 + 5 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx create mode 100644 packages/webapp/hooks/useProfileSidebarCollapse.ts diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx new file mode 100644 index 0000000000..6e1c416cde --- /dev/null +++ b/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx @@ -0,0 +1,262 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useState, useMemo } from 'react'; +import classNames from 'classnames'; +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 { AUTHOR_FEED_QUERY, USER_UPVOTED_FEED_QUERY } from '../../../../graphql/feed'; +import { OtherFeedPage } from '../../../../lib/query'; +import { MyProfileEmptyScreen } from '../../../../components/profile/MyProfileEmptyScreen'; +import { ProfileEmptyScreen } from '../../../../components/profile/ProfileEmptyScreen'; +import AuthContext from '../../../../contexts/AuthContext'; +import CommentFeed from '../../../../components/CommentFeed'; +import { USER_COMMENTS_QUERY } from '../../../../graphql/comments'; +import { Origin } from '../../../../lib/log'; +import { generateQueryKey, RequestKey } from '../../../../lib/query'; +import { link } from '../../../../lib/links'; +import { useHorizontalScrollHeader } from '../../../../components/HorizontalScroll/useHorizontalScrollHeader'; +import { TypographyType, Typography } from '../../../../components/typography/Typography'; +import { ArrowIcon } from '../../../../components/icons'; +import Link from '../../../../components/utilities/Link'; +import FeedContext from '../../../../contexts/FeedContext'; + +export type ActivityTab = { id: string; title: string; path: string }; + +export const activityTabs: ActivityTab[] = [ + { + id: 'posts', + title: 'Posts', + path: '/posts', + }, + { + id: 'replies', + title: 'Replies', + path: '/replies', + }, + { + id: 'upvoted', + title: 'Upvoted', + path: '/upvoted', + }, +]; + +interface ActivityProps { + user: PublicProfile; +} + +const commentClassName = { + container: 'rounded-none border-0 border-b', + commentBox: { + container: 'relative border-0 rounded-none', + }, +}; + +const CARD_WIDTH = 272; +const CARD_GAP = 16; + +export const Activity = ({ user }: ActivityProps): ReactElement => { + const [selectedTab, setSelectedTab] = useState(0); + const { user: loggedUser } = useContext(AuthContext); + const currentFeedSettings = useContext(FeedContext); + const isSameUser = user && loggedUser?.id === user.id; + const userId = user?.id; + + const getUserPath = (path: string) => { + const username = user?.username || user?.id; + return `/${username}${path}`; + }; + + // Calculate numCards based on container width + // Assuming parent has p-6 (24px padding) and we need to fit 272px cards with 16px gaps + const feedContextValue = useMemo(() => { + // Calculate based on typical mobile/tablet widths + // For a container with ~600px available width: (600 + 16) / (272 + 16) โ‰ˆ 2.14 cards + // We'll show 2 cards to ensure good scrolling UX + const calculatedNumCards = 3; + + return { + ...currentFeedSettings, + numCards: { + eco: calculatedNumCards, + roomy: calculatedNumCards, + cozy: calculatedNumCards, + }, + }; + }, [currentFeedSettings]); + + const postsFeedProps: FeedProps = { + feedName: OtherFeedPage.Author, + feedQueryKey: ['author', userId], + query: AUTHOR_FEED_QUERY, + variables: { + userId, + }, + disableAds: true, + allowFetchMore: false, + pageSize: 10, + isHorizontal: true, + emptyScreen: isSameUser ? ( + + ) : ( + + ), + }; + + const upvotedFeedProps: FeedProps = { + feedName: OtherFeedPage.UserUpvoted, + feedQueryKey: ['user_upvoted', userId], + query: USER_UPVOTED_FEED_QUERY, + variables: { + userId, + }, + disableAds: true, + allowFetchMore: false, + pageSize: 10, + isHorizontal: true, + emptyScreen: isSameUser ? ( + + ) : ( + + ), + }; + + const commentsEmptyScreen = isSameUser ? ( + + ) : ( + + ); + + const { ref, isAtStart, isAtEnd, onClickNext, onClickPrevious, isOverflowing } = + useHorizontalScrollHeader({ + title: { copy: 'Activity', type: TypographyType.Body }, + }); + + const renderContent = () => { + switch (selectedTab) { + case 0: // Posts + return ( + +
      + +
      +
      + ); + case 1: // Replies + return ( + + ); + case 2: // Upvoted + return ( + +
      + +
      +
      + ); + default: + return null; + } + }; + + return ( +
      +
      + + Activity + +
      +
      + {activityTabs.map((tab, index) => ( + + ))} +
      + {isOverflowing && selectedTab !== 1 && ( +
      +
      + )} +
      +
      + {renderContent()} + {selectedTab !== 1 && ( + + + + )} +
      + ); +}; + 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..4bde4cd74d 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,12 @@ export default function ProfileLayout({ } return ( -
      +
      -
      {children}
      -
      + ); +}; diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx deleted file mode 100644 index c5c1d335f1..0000000000 --- a/packages/shared/src/features/profile/components/ProfileWidgets/Activity.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useContext, useState, useMemo } 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 { AUTHOR_FEED_QUERY, USER_UPVOTED_FEED_QUERY } from '../../../../graphql/feed'; -import { OtherFeedPage } from '../../../../lib/query'; -import { MyProfileEmptyScreen } from '../../../../components/profile/MyProfileEmptyScreen'; -import { ProfileEmptyScreen } from '../../../../components/profile/ProfileEmptyScreen'; -import AuthContext from '../../../../contexts/AuthContext'; -import CommentFeed from '../../../../components/CommentFeed'; -import { USER_COMMENTS_QUERY } from '../../../../graphql/comments'; -import { Origin } from '../../../../lib/log'; -import { generateQueryKey, RequestKey } from '../../../../lib/query'; -import { link } from '../../../../lib/links'; -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'; - -export type ActivityTab = { id: string; title: string; path: string }; - -export const activityTabs: ActivityTab[] = [ - { - id: 'posts', - title: 'Posts', - path: '/posts', - }, - { - id: 'replies', - title: 'Replies', - path: '/replies', - }, - { - id: 'upvoted', - title: 'Upvoted', - path: '/upvoted', - }, -]; - -interface ActivityProps { - user: PublicProfile; -} - -const commentClassName = { - container: 'rounded-none border-0 border-b', - commentBox: { - container: 'relative border-0 rounded-none', - }, -}; - -export const Activity = ({ user }: ActivityProps): ReactElement => { - const [selectedTab, setSelectedTab] = useState(activityTabs[0].title); - const { user: loggedUser } = useContext(AuthContext); - const currentFeedSettings = useContext(FeedContext); - const isSameUser = user && loggedUser?.id === user.id; - const userId = user?.id; - - const selectedTabIndex = activityTabs.findIndex(tab => tab.title === selectedTab); - - const getUserPath = (path: string) => { - const username = user?.username || user?.id; - return `/${username}${path}`; - }; - - const feedContextValue = useMemo(() => { - // We'll show 3 cards to make sure enough cards show on most screens - const numCards = 3; - - return { - ...currentFeedSettings, - numCards: { - eco: numCards, - roomy: numCards, - cozy: numCards, - }, - }; - }, [currentFeedSettings]); - - const postsFeedProps: FeedProps = { - feedName: OtherFeedPage.Author, - feedQueryKey: ['author', userId], - query: AUTHOR_FEED_QUERY, - variables: { - userId, - }, - disableAds: true, - allowFetchMore: false, - pageSize: 10, - isHorizontal: true, - emptyScreen: isSameUser ? ( - - ) : ( - - ), - }; - - const upvotedFeedProps: FeedProps = { - feedName: OtherFeedPage.UserUpvoted, - feedQueryKey: ['user_upvoted', userId], - query: USER_UPVOTED_FEED_QUERY, - variables: { - userId, - }, - disableAds: true, - allowFetchMore: false, - pageSize: 10, - isHorizontal: true, - emptyScreen: isSameUser ? ( - - ) : ( - - ), - }; - - const commentsEmptyScreen = isSameUser ? ( - - ) : ( - - ); - - // Query to check if there are more than 3 posts - const { data: postsData } = useQuery({ - queryKey: ['author', userId], - enabled: false, // Don't auto-fetch, Feed component handles fetching - }); - - // Query to check if there are more than 3 upvoted posts - const { data: upvotedData } = useQuery({ - queryKey: [OtherFeedPage.UserUpvoted, userId], - enabled: false, - }); - - // Query to check if there are more than 3 comments - const { data: commentsData } = useQuery({ - queryKey: generateQueryKey(RequestKey.UserComments, null, userId), - enabled: false, - }); - - // Check if current tab has more than 3 items to show "Show More" button - const shouldShowMoreButton = useMemo(() => { - const MIN_ITEMS = 3; - - switch (selectedTabIndex) { - case 0: // Posts - return ((postsData as any)?.pages?.[0]?.page?.edges?.length ?? 0) > MIN_ITEMS; - case 1: // Replies - return ((commentsData as any)?.page?.edges?.length ?? 0) > MIN_ITEMS; - case 2: // Upvoted - return ((upvotedData as any)?.pages?.[0]?.page?.edges?.length ?? 0) > MIN_ITEMS; - default: - return false; - } - }, [selectedTabIndex, postsData, commentsData, upvotedData]); - - // Render custom title content (shared across all tabs) - const renderTitleContent = () => ( -
      - - Activity - - ({ label: tab.title }))} - active={selectedTab} - onClick={(label) => setSelectedTab(label)} - className={{ item: '!p-0 !pr-3', indicator: 'hidden' }} - /> -
      - ); - - // Use horizontal scroll header (with navigation arrows) for Posts/Upvoted - const { ref, header: horizontalHeader } = useHorizontalScrollHeader({ - title: renderTitleContent(), - className: '!items-end !mb-0', - buttonSize: ButtonSize.Small, - }); - - const renderContent = () => { - switch (selectedTabIndex) { - case 0: // Posts - return ( - - - - ); - case 1: // Replies - return ( - - ); - case 2: // Upvoted - return ( - - - - ); - default: - return null; - } - }; - - // Conditionally render header based on tab type - const renderHeader = () => { - // Replies tab: just show title (no horizontal scroll arrows) - if (selectedTabIndex === 1) { - return ( -
      - {renderTitleContent()} -
      - ); - } - - // Posts/Upvoted tabs: show title + horizontal scroll arrows - return horizontalHeader; - }; - - return ( -
      - {renderHeader()} - {renderContent()} - {shouldShowMoreButton && ( - - - - )} -
      - ); -}; - diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index 4bde4cd74d..7c523516a7 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -111,12 +111,14 @@ export default function ProfileLayout({ } return ( -
      +
      -
      {children}
      -