mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Show the stored avatar on the public profile page
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user