diff --git a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md index 24fa5a83..53cb347e 100644 --- a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md +++ b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md @@ -6,21 +6,21 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of ## Status legend - ✅ **Available**: real data, wired up. -- ⚠️ **Partial**: schema/repository exists but no public read endpoint. +- ⚠️ **Partial**: reads real data, but a related gap remains (see the Gap column). - ❌ **Missing**: no backend support; page shows filler or local-only state. ## Header & sidebar -| Field | Status | Source | Gap | -| ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | -| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | -| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. | -| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. | -| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. | -| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. | -| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). | -| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. | +| Field | Status | Source | Gap | +| ---------------------------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | +| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | +| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. | +| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. | +| Avatar photo | ⚠️ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads the current `Social.UserAvatar` photo via the left joins in `UserListRepository.GetByIdAsync` and signs it into `avatarUrl`; an account with no avatar returns an empty string and `ProfileAvatar` shows initials. `UploadAvatarCommand` has no HTTP route yet, so only accounts created by `Database.Seed` have one — see (4) below. | +| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. | +| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). | +| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. | ## Follow action @@ -36,7 +36,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of | Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. | | Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. | -Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed. +Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (3) below closes and comments/follows are loader-backed. ## Activity / Following / Liked tabs @@ -49,8 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi ## Summary of backend work -1. A public query + controller route to read a user's stored `Social.UserAvatar`. -2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap). -3. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts. -4. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first. -5. Once (3) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty. +1. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept. +2. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts. +3. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first. +4. An avatar-upload endpoint: `UploadAvatarCommand`/`UploadAvatarHandler` exist and `Database.Seed` sends them, but no controller action does, so a signed-in user has no way to set their own avatar. Needs a `multipart/form-data` route on the authenticated caller's own account (`IFormFile`, same validation the seeder's uploads pass through). +5. Once (2) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty. diff --git a/web/frontend/app/features/users/components/profile/ProfileAvatar.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileAvatar.stories.tsx new file mode 100644 index 00000000..fb077adb --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileAvatar.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import ProfileAvatar from './ProfileAvatar'; + +const profileAvatarDescription = `Avatar shown at the top of the public profile sidebar. It renders the photo from the public profile payload's \`avatarUrl\`, and shows the first two letters of the display name when that field is empty or the image fails to load.`; + +const meta = { + title: 'Users/ProfileAvatar', + component: ProfileAvatar, + tags: ['autodocs'], + args: { + displayName: 'Maren Iverson', + avatarUrl: 'https://picsum.photos/id/1027/240/240', + }, + parameters: { + layout: 'centered', + docs: { + description: { + component: profileAvatarDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithPhoto: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const image = canvas.getByRole('img', { name: "Maren Iverson's avatar" }); + await expect(image).toHaveAttribute('src', 'https://picsum.photos/id/1027/240/240'); + }, +}; + +export const WithoutPhoto: Story = { + args: { avatarUrl: '' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('MA')).toBeInTheDocument(); + await expect(canvas.queryByRole('img')).not.toBeInTheDocument(); + }, +}; + +export const BrokenPhotoUrl: Story = { + args: { avatarUrl: 'https://example.invalid/missing-avatar.jpg' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('MA')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/users/components/profile/ProfileAvatar.tsx b/web/frontend/app/features/users/components/profile/ProfileAvatar.tsx new file mode 100644 index 00000000..00bdd598 --- /dev/null +++ b/web/frontend/app/features/users/components/profile/ProfileAvatar.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +interface ProfileAvatarProps { + avatarUrl: string; + displayName: string; +} + +/** + * Renders the user's current avatar photo, falling back to their initials when the public + * profile payload carries an empty `avatarUrl` or the hosted image fails to load. + */ +const ProfileAvatar = ({ avatarUrl, displayName }: ProfileAvatarProps) => { + const [didImageFail, setDidImageFail] = useState(false); + const showPhoto = avatarUrl.length > 0 && !didImageFail; + + return ( +
+ {showPhoto ? ( +
+ {`${displayName}'s setDidImageFail(true)} + /> +
+ ) : ( +
+ + {displayName.slice(0, 2).toUpperCase()} + +
+ )} +
+ ); +}; + +export default ProfileAvatar; diff --git a/web/frontend/app/features/users/routes/user-profile.tsx b/web/frontend/app/features/users/routes/user-profile.tsx index 6ec2d507..f4de9901 100644 --- a/web/frontend/app/features/users/routes/user-profile.tsx +++ b/web/frontend/app/features/users/routes/user-profile.tsx @@ -3,6 +3,7 @@ import { data } from 'react-router'; import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import { getOptionalAuth } from '../../auth/auth.server'; import { getPublicUserProfile, type PublicUserProfile } from '../users.server'; +import ProfileAvatar from '../components/profile/ProfileAvatar'; import ProfileSidebarCard from '../components/profile/ProfileSidebarCard'; import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs'; import ActivityTab from '../components/profile/tabs/ActivityTab'; @@ -80,15 +81,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
-
-
-
- - {displayName.slice(0, 2).toUpperCase()} - -
-
-
+