diff --git a/src/webapp/features/profile/components/botLabel/BotLabel.stories.tsx b/src/webapp/features/profile/components/botLabel/BotLabel.stories.tsx new file mode 100644 index 00000000..10c65d90 --- /dev/null +++ b/src/webapp/features/profile/components/botLabel/BotLabel.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import BotLabel from './BotLabel'; + +const meta: Meta = { + title: 'Features/Profile/BotLabel', + component: BotLabel, +}; + +export default meta; + +type Story = StoryObj; + +/** Tooltip icon shown on bot accounts. Hover to see the label. */ +export const Default: Story = {}; diff --git a/src/webapp/features/profile/components/profileCard/ProfileCard.stories.tsx b/src/webapp/features/profile/components/profileCard/ProfileCard.stories.tsx new file mode 100644 index 00000000..89b5bb5b --- /dev/null +++ b/src/webapp/features/profile/components/profileCard/ProfileCard.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { User } from '@semble/types'; +import { Badge } from '@mantine/core'; +import ProfileCard from './ProfileCard'; +import ProfileCardSkeleton from './Skeleton.ProfileCard'; + +const baseProfile: User = { + id: 'did:plc:abc123def456', + name: 'Elena Kowalski', + handle: 'elena.kowalski', + avatarUrl: 'https://i.pravatar.cc/150?u=elena', + description: 'Researcher & collector of interesting things on the web.', + followerCount: 342, + followingCount: 89, + followsYou: false, +}; + +const meta: Meta = { + title: 'Features/Profile/ProfileCard', + component: ProfileCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + profile: baseProfile, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Default profile card with avatar, name, handle and description. */ +export const Default: Story = {}; + +/** Card showing when the profile was added, rendered as relative time. */ +export const WithAddedAt: Story = { + args: { + addedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), // 3 hours ago + }, +}; + +/** Badge shown when the profile follows the current user. */ +export const FollowsYou: Story = { + args: { + profile: { + ...baseProfile, + followsYou: true, + }, + }, +}; + +/** Bot accounts display the robot icon label next to the name. */ +export const BotAccount: Story = { + args: { + profile: { + ...baseProfile, + id: 'did:plc:bot999', + name: 'Semble Bot', + handle: 'bot.semble', + avatarUrl: 'https://i.pravatar.cc/150?u=bot', + description: 'An automated bot that curates content.', + labels: [{ val: 'bot', src: 'did:plc:bot999', uri: 'did:plc:bot999', cts: '2024-01-01T00:00:00.000Z' }], + }, + }, +}; + +/** Long descriptions are clamped to two lines. */ +export const LongDescription: Story = { + args: { + profile: { + ...baseProfile, + description: + 'Frontend engineer by day, avid reader by night. I collect links about design systems, web performance, accessibility, TypeScript patterns, and anything else that makes the web a better place. Always happy to discuss tech, books, or coffee.', + }, + }, +}; + +/** Card with no avatar falls back to the Mantine Avatar placeholder. */ +export const NoAvatar: Story = { + args: { + profile: { + ...baseProfile, + avatarUrl: undefined, + }, + }, +}; + +/** Additional content (e.g. a badge) can be passed as children. */ +export const WithChildren: Story = { + args: { + children: ( + + Mutual + + ), + }, +}; + +export const Skeleton: Story = { + render: () => , +}; diff --git a/src/webapp/features/profile/components/profileEmptyTab/ProfileEmptyTab.stories.tsx b/src/webapp/features/profile/components/profileEmptyTab/ProfileEmptyTab.stories.tsx new file mode 100644 index 00000000..f0232868 --- /dev/null +++ b/src/webapp/features/profile/components/profileEmptyTab/ProfileEmptyTab.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Button } from '@mantine/core'; +import { FaRegNoteSticky } from 'react-icons/fa6'; +import { BiCollection } from 'react-icons/bi'; +import { MdPersonSearch } from 'react-icons/md'; +import ProfileEmptyTab from './ProfileEmptyTab'; + +const meta: Meta = { + title: 'Features/Profile/ProfileEmptyTab', + component: ProfileEmptyTab, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + message: 'No cards yet', + icon: FaRegNoteSticky, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Empty state for a tab with no content. */ +export const Default: Story = {}; + +/** Empty state with an action button to prompt the user. */ +export const WithButton: Story = { + args: { + message: 'No cards yet', + icon: FaRegNoteSticky, + button: , + }, +}; + +/** Empty collections tab. */ +export const Collections: Story = { + args: { + message: 'No collections yet', + icon: BiCollection, + }, +}; + +/** Empty connections tab with a call-to-action. */ +export const Connections: Story = { + args: { + message: 'No connections yet', + icon: MdPersonSearch, + button: , + }, +}; diff --git a/src/webapp/features/profile/components/profileFollowStats/ProfileFollowStats.stories.tsx b/src/webapp/features/profile/components/profileFollowStats/ProfileFollowStats.stories.tsx new file mode 100644 index 00000000..53158982 --- /dev/null +++ b/src/webapp/features/profile/components/profileFollowStats/ProfileFollowStats.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { User } from '@semble/types'; +import { Suspense, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import ProfileFollowStats from './ProfileFollowStats'; +import FollowStatsSkeleton from './Skeleton.FollowStats'; +import { profileKeys } from '../../lib/profileKeys'; + +const HANDLE = 'elena.kowalski'; + +const mockProfile: User = { + id: 'did:plc:abc123def456', + name: 'Elena Kowalski', + handle: HANDLE, + avatarUrl: 'https://i.pravatar.cc/150?u=elena', + followerCount: 342, + followingCount: 89, + followedCollectionsCount: 17, +}; + +function QueryCacheSeed({ + profile, + children, +}: { + profile: User; + children: React.ReactNode; +}) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.setQueryData(profileKeys.profile(profile.handle), profile); + }, [queryClient, profile]); + + return <>{children}; +} + +const meta: Meta = { + title: 'Features/Profile/ProfileFollowStats', + component: ProfileFollowStats, + decorators: [ + (Story) => ( + + }> + + + + ), + ], + args: { + handle: HANDLE, + initialFollowerCount: mockProfile.followerCount!, + initialFollowingCount: mockProfile.followingCount!, + initialFollowedCollectionsCount: mockProfile.followedCollectionsCount!, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Follow statistics row with follower, following and collections counts. */ +export const Default: Story = {}; + +/** Singular "Follower" label when count is exactly one. */ +export const SingleFollower: Story = { + args: { + handle: HANDLE, + initialFollowerCount: 1, + initialFollowingCount: 0, + initialFollowedCollectionsCount: 0, + }, + decorators: [ + (Story) => ( + + }> + + + + ), + ], +}; + +/** Skeleton placeholder shown while data loads. */ +export const Skeleton: Story = { + render: () => , +}; diff --git a/src/webapp/features/profile/components/profileHeader/MinimalProfileHeader.stories.tsx b/src/webapp/features/profile/components/profileHeader/MinimalProfileHeader.stories.tsx new file mode 100644 index 00000000..7ed66e02 --- /dev/null +++ b/src/webapp/features/profile/components/profileHeader/MinimalProfileHeader.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import MinimalProfileHeader from './MinimalProfileHeader'; +import ProfileHeaderSkeleton from './Skeleton.ProfileHeader'; + +const meta: Meta = { + title: 'Features/Profile/MinimalProfileHeader', + component: MinimalProfileHeader, + args: { + name: 'Elena Kowalski', + handle: 'elena.kowalski', + avatarUrl: 'https://i.pravatar.cc/150?u=elena', + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Sticky mini header shown when scrolling past the main profile header. */ +export const Default: Story = {}; + +/** Falls back to the Mantine Avatar placeholder when no avatar URL is provided. */ +export const NoAvatar: Story = { + args: { + avatarUrl: undefined, + }, +}; + +/** Long names are clamped to a single line. */ +export const LongName: Story = { + args: { + name: 'Dr. Alexandria Pemberton-Worthington III', + handle: 'alexandria.pemberton', + }, +}; + +/** Full-page profile header skeleton shown during initial load. */ +export const Skeleton: Story = { + render: () => , +}; diff --git a/src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.stories.tsx b/src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.stories.tsx new file mode 100644 index 00000000..4f8de112 --- /dev/null +++ b/src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { User } from '@semble/types'; +import { Suspense, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Text } from '@mantine/core'; +import ProfileHoverCard from './ProfileHoverCard'; +import { profileKeys } from '../../lib/profileKeys'; + +const HANDLE = 'elena.kowalski'; + +const mockProfile: User = { + id: 'did:plc:abc123def456', + name: 'Elena Kowalski', + handle: HANDLE, + avatarUrl: 'https://i.pravatar.cc/150?u=elena', + description: 'Researcher & collector of interesting things on the web.', + followerCount: 342, + followingCount: 89, + followedCollectionsCount: 17, + urlCardCount: 58, + collectionCount: 6, +}; + +function QueryCacheSeed({ + profile, + children, +}: { + profile: User; + children: React.ReactNode; +}) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.setQueryData(profileKeys.profile(profile.handle), profile); + }, [queryClient, profile]); + + return <>{children}; +} + +const meta: Meta = { + title: 'Features/Profile/ProfileHoverCard', + component: ProfileHoverCard, + decorators: [ + (Story) => ( + + + + + + ), + ], + args: { + didOrHandle: HANDLE, + children: @{HANDLE}, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Hover over the handle to see the profile card dropdown. */ +export const Default: Story = {}; + +/** Bot account label is shown next to the name. */ +export const BotAccount: Story = { + args: { + didOrHandle: 'bot.semble', + children: @bot.semble, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +}; + +/** Profile with no description. */ +export const NoDescription: Story = { + args: { + didOrHandle: 'minimal.user', + children: @minimal.user, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +}; diff --git a/src/webapp/features/profile/components/profileMenu/ProfileMenu.stories.tsx b/src/webapp/features/profile/components/profileMenu/ProfileMenu.stories.tsx new file mode 100644 index 00000000..0ef3d33d --- /dev/null +++ b/src/webapp/features/profile/components/profileMenu/ProfileMenu.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { User } from '@semble/types'; +import { Suspense, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Skeleton as MantineSkeleton } from '@mantine/core'; +import ProfileMenu from './ProfileMenu'; +import { profileKeys } from '../../lib/profileKeys'; +import { AuthContext, type AuthContextType } from '@/hooks/useAuth'; + +const mockProfile: User = { + id: 'did:plc:abc123def456', + name: 'Elena Kowalski', + handle: 'elena.kowalski', + avatarUrl: 'https://i.pravatar.cc/150?u=elena', +}; + +const mockAuthContext: AuthContextType = { + user: null, + isAuthenticated: true, + isLoading: false, + refreshAuth: async () => {}, + logout: async () => {}, +}; + +function QueryCacheSeed({ + profile, + children, +}: { + profile: User; + children: React.ReactNode; +}) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.setQueryData(profileKeys.mine(), profile); + }, [queryClient, profile]); + + return <>{children}; +} + +const meta: Meta = { + title: 'Features/Profile/ProfileMenu', + component: ProfileMenu, + decorators: [ + (Story) => ( + + + }> +
+ +
+
+
+
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +/** Collapsed menu trigger shown in the navbar footer. Click to open. */ +export const Default: Story = {}; + +/** Bot accounts show the robot icon next to the name. */ +export const BotAccount: Story = { + decorators: [ + (Story) => ( + + + }> +
+ +
+
+
+
+ ), + ], +}; + +/** Skeleton shown while the profile data is loading. */ +export const Skeleton: Story = { + render: () => , +}; diff --git a/src/webapp/features/profile/components/profileTabs/ProfileTabs.stories.tsx b/src/webapp/features/profile/components/profileTabs/ProfileTabs.stories.tsx new file mode 100644 index 00000000..d199ea9e --- /dev/null +++ b/src/webapp/features/profile/components/profileTabs/ProfileTabs.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { User } from '@semble/types'; +import { Suspense, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Skeleton } from '@mantine/core'; +import ProfileTabs from './ProfileTabs'; +import { profileKeys } from '../../lib/profileKeys'; + +const HANDLE = 'elena.kowalski'; + +const mockProfile: User = { + id: 'did:plc:abc123def456', + name: 'Elena Kowalski', + handle: HANDLE, + avatarUrl: 'https://i.pravatar.cc/150?u=elena', + followerCount: 342, + followingCount: 89, + urlCardCount: 58, + collectionCount: 6, + connectionCount: 12, +}; + +function QueryCacheSeed({ + profile, + children, +}: { + profile: User; + children: React.ReactNode; +}) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.setQueryData( + profileKeys.profile(profile.handle, true), + profile, + ); + }, [queryClient, profile]); + + return <>{children}; +} + +const meta: Meta = { + title: 'Features/Profile/ProfileTabs', + component: ProfileTabs, + parameters: { + nextjs: { + navigation: { + pathname: `/profile/${HANDLE}`, + }, + }, + }, + decorators: [ + (Story) => ( + + }> + + + + ), + ], + args: { + handle: HANDLE, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Tab bar on the Profile tab (default view). */ +export const ProfileTab: Story = {}; + +/** Tab bar with the Cards tab active. */ +export const CardsTab: Story = { + parameters: { + nextjs: { + navigation: { + pathname: `/profile/${HANDLE}/cards`, + }, + }, + }, +}; + +/** Tab bar with the Collections tab active. */ +export const CollectionsTab: Story = { + parameters: { + nextjs: { + navigation: { + pathname: `/profile/${HANDLE}/collections`, + }, + }, + }, +}; + +/** Tab bar with the Connections tab active. */ +export const ConnectionsTab: Story = { + parameters: { + nextjs: { + navigation: { + pathname: `/profile/${HANDLE}/connections`, + }, + }, + }, +};