Show the stored avatar on the public profile page

This commit is contained in:
Aaron Po
2026-10-11 22:20:31 -04:00
parent 43eb3a1ddb
commit b41ae73e93
6 changed files with 116 additions and 27 deletions

View File

@@ -6,21 +6,21 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
## Status legend ## Status legend
- ✅ **Available**: real data, wired up. - ✅ **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. - ❌ **Missing**: no backend support; page shows filler or local-only state.
## Header & sidebar ## Header & sidebar
| Field | Status | Source | Gap | | Field | Status | Source | Gap |
| ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | | Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | | `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. | | 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`. | | 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. | | 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. | | 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 = ?`). | | 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`. | | 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 ## 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. | | 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. | | 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 ## Activity / Following / Liked tabs
@@ -49,8 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi
## Summary of backend work ## Summary of backend work
1. A public query + controller route to read a user's stored `Social.UserAvatar`. 1. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept.
2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap). 2. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
3. `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 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 (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. 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.

View File

@@ -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<typeof ProfileAvatar>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};

View File

@@ -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 (
<div
className={`avatar w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md ${
showPhoto ? '' : 'avatar-placeholder'
}`}
>
{showPhoto ? (
<div className="w-full h-full rounded-full overflow-hidden">
<img
src={avatarUrl}
alt={`${displayName}'s avatar`}
className="w-full h-full object-cover"
onError={() => setDidImageFail(true)}
/>
</div>
) : (
<div className="bg-secondary text-secondary-content w-full h-full rounded-full flex items-center justify-center">
<span className="text-2xl font-bold">
{displayName.slice(0, 2).toUpperCase()}
</span>
</div>
)}
</div>
);
};
export default ProfileAvatar;

View File

@@ -3,6 +3,7 @@ import { data } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getOptionalAuth } from '../../auth/auth.server'; import { getOptionalAuth } from '../../auth/auth.server';
import { getPublicUserProfile, type PublicUserProfile } from '../users.server'; import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
import ProfileAvatar from '../components/profile/ProfileAvatar';
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard'; import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs'; import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
import ActivityTab from '../components/profile/tabs/ActivityTab'; import ActivityTab from '../components/profile/tabs/ActivityTab';
@@ -80,15 +81,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12"> <div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2"> <div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden"> <ProfileAvatar avatarUrl={profile.avatarUrl} displayName={displayName} />
<div className="avatar avatar-placeholder w-full h-full">
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
<span className="text-2xl font-bold">
{displayName.slice(0, 2).toUpperCase()}
</span>
</div>
</div>
</div>
<ProfileSidebarCard <ProfileSidebarCard
displayName={displayName} displayName={displayName}

View File

@@ -8,6 +8,7 @@ export interface PublicUserProfile {
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string; biography: string;
avatarUrl: string;
createdAt: string; createdAt: string;
} }
@@ -49,6 +50,7 @@ export const getPublicUserProfile = async (
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string; biography: string;
avatarUrl: string;
createdAt: string; createdAt: string;
} = await res.json(); } = await res.json();
@@ -58,6 +60,7 @@ export const getPublicUserProfile = async (
firstName: account.firstName, firstName: account.firstName,
lastName: account.lastName, lastName: account.lastName,
biography: account.biography, biography: account.biography,
avatarUrl: account.avatarUrl,
createdAt: account.createdAt, createdAt: account.createdAt,
}; };
}; };

View File

@@ -1,5 +1,5 @@
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for // see USER_PROFILE_HANDOFF.md — filler data pending backend support for
// bio, location, avatar/cover photos, follower/following stats, and activity feeds. // location, cover photos, follower/following stats, and activity feeds.
export interface FillerProfileMeta { export interface FillerProfileMeta {
bio: string; bio: string;