feat: social frontend — feed, discover, profiles, likes, comments, notifications (#27)#28
Conversation
…tifications - New pages: /feed (infinite scroll, followed users), /discover (trending, public) - New pages: /profile/[id] (avatar, bio, stats, tabs: recipes/followers/following) - New pages: /notifications (paginated, mark-all-read, unread badge) - LikeButton: optimistic update with heart animation, rollback on error - FollowButton: optimistic update on follower count, rollback on error - CommentThread: infinite scroll, post/delete, lazy load on open - NotificationBell: polls unread count every 30s, badge in Navbar - RecipeCardSocial: author avatar+link, like/comment bar, owner actions - Avatar component with fallback initials, Skeleton loaders for all async views - socialApi + feedApi modules for clean server-state separation - date-fns added for relative timestamps - Discover page added to public middleware paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sadykovIsmail
left a comment
There was a problem hiding this comment.
Code Review — Social Frontend
Architecture & Patterns ✅
API layer split — socialApi (user/follow/notifications) and feedApi (recipes/likes/comments) are clean domain-separated modules. Each function is a single-responsibility async call returning a typed response. No raw Axios calls in components. ✅
TanStack Query v5 patterns — useInfiniteQuery with initialPageParam: 1 and getNextPageParam returning pages.length + 1 is idiomatic for offset-based pagination. enabled flag on profile-recipe query (only when isOwnProfile) prevents unauthorized calls to the authenticated endpoint. ✅
Optimistic updates — FollowButton and LikeButton both follow the canonical pattern: cancel in-flight queries → snapshot previous state → apply optimistic state → on error: restore snapshot → on success: invalidate to sync. This is the correct FAANG-level UX pattern. ✅
Performance ✅
IntersectionObserver for infinite scroll — Using a sentinelRef callback with a cleanup disconnect is correct. The implementation avoids scroll listeners (which fire on every pixel) in favor of the observer API. threshold: 0 (default) triggers as soon as the sentinel enters the viewport.
NotificationBell polling — refetchInterval: 30_000 with staleTime: 20_000 means: serve cache for up to 20s, refetch in background after 20s, always refetch from network after 30s. This is correct — avoids hammering the API while keeping the count reasonably fresh.
Skeleton loaders — RecipeCardSkeleton mirrors the card layout exactly, preventing layout shift when data loads. Good.
Potential Issues 🔍
-
CommentThread imports
date-fns—date-fnshas been added topackage.jsonbut not yet run throughnpm install. The Dockerfile will handle this, but note that thepackage-lock.jsonwill be stale untilnpm installruns inside Docker. This is expected behavior. -
Profile page own-recipe tab — The profile page uses the authenticated
/api/v1/recipe/recipes/endpoint which returns only the current user's recipes. This means visiting another user's/profile/:idand clicking "Recipes" shows an empty state with a "Follow to see their recipes" message — intentional, but document this in a comment for future devs since it's non-obvious. -
use(params)in Profile page — Uses React 19'suse()hook for async params unwrapping. This is correct for Next.js 15, but the project is on Next.js 14.2.5. In Next.js 14,paramsis a plain object, not a Promise — usinguse()will cause a runtime error. Change toconst { id } = params;and acceptparams: { id: string }directly. -
Discover page main container — The
(dashboard)/layout.tsxalready wraps children in amax-w-7xl mx-auto px-4container. The Discover page adds its ownmax-w-4xl mx-auto px-4which double-wraps the padding. Remove the outer padding from the page component and rely on the layout's padding. -
Missing
keyprop stability — InCommentThread,[...Array(3)].map((_, i) => ...)uses index as key which is fine for static skeleton arrays. For actual data, thecomment.idkey is correct.
Security ✅
- Comment delete button is guarded by
c.user.id === currentUserId || recipeOwnerId === currentUserIdon the frontend. The backend enforces this independently — frontend guard is just UX, not security. Both layers present. ✅ rel="noopener noreferrer"on all external links. ✅- No
dangerouslySetInnerHTML. ✅
Critical Fix Required
Issue #3 above is blocking — use(params) will throw in Next.js 14. Must change profile page params typing before merging.
Requesting one fix before merge approval:
frontend/src/app/(dashboard)/profile/[id]/page.tsx: changeparams: Promise<{ id: string }>+use(params)→params: { id: string }+const { id } = params;
Reviewed ✅ — pending one fix
use() for async params is Next.js 15+. On 14.x params is a plain object. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a social-oriented frontend experience (feed/discover/profile/notifications) on top of the existing recipe app, including new UI components and API clients to support follows, likes, comments, and notification UX.
Changes:
- Introduces social data types and API clients for feed/discover, profiles/follow graph, and notifications.
- Adds new social UI components (social recipe card, like/follow buttons, comment thread, notification bell) and new pages (feed, discover, profile, notifications).
- Updates the dashboard navbar and allows
/discoverthrough the Next middleware public-path list.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/types/index.ts | Adds social/notification/comment/author types and extends User/Recipe models. |
| frontend/src/middleware.ts | Adds /discover to PUBLIC_PATHS. |
| frontend/src/lib/api/social.ts | New social API client (profiles, follow, notifications, search). |
| frontend/src/lib/api/feed.ts | New feed/discover + like/comment API client. |
| frontend/src/components/ui/Skeleton.tsx | Adds skeleton loading components for social pages. |
| frontend/src/components/ui/Avatar.tsx | Adds avatar component with fallback initials. |
| frontend/src/components/social/UserCard.tsx | Adds user list card with follow action. |
| frontend/src/components/social/RecipeCardSocial.tsx | Adds social recipe card UI with author + like/comment actions. |
| frontend/src/components/social/NotificationBell.tsx | Adds polling unread-notification badge in navbar. |
| frontend/src/components/social/LikeButton.tsx | Adds like/unlike button with mutation wiring + animation. |
| frontend/src/components/social/FollowButton.tsx | Adds follow/unfollow with optimistic profile cache update. |
| frontend/src/components/social/CommentThread.tsx | Adds expandable comment list with post/delete + pagination. |
| frontend/src/components/layout/Navbar.tsx | Updates navigation to include Feed/Discover + notification bell + profile link. |
| frontend/src/app/(dashboard)/profile/[id]/page.tsx | Adds profile page with tabs and infinite-scroll lists. |
| frontend/src/app/(dashboard)/notifications/page.tsx | Adds notifications list page with mark-all-read + pagination. |
| frontend/src/app/(dashboard)/feed/page.tsx | Adds authenticated feed page with infinite scroll + empty state CTA. |
| frontend/src/app/(dashboard)/discover/page.tsx | Adds discover page with search + infinite scroll grid. |
| frontend/package.json | Adds date-fns for relative timestamps. |
Comments suppressed due to low confidence (2)
frontend/src/components/layout/Navbar.tsx:76
- Navbar renders
NotificationBell, “New Recipe”, and “Log out” even whenuseris undefined. On a public page like/discover, these can trigger authenticated requests (or redirects on 401) and undermine the “no auth required” goal; conditionally render authenticated actions (bell/new/logout) based on auth state.
{/* Right actions */}
<div className="flex items-center gap-3">
<Link href="/recipes/new">
<Button size="sm" variant="primary">
<PlusCircle className="h-4 w-4 mr-1.5" />
New Recipe
</Button>
</Link>
<NotificationBell />
{user && (
<Link
href={`/profile/${user.id}`}
className="hidden sm:flex items-center gap-1.5 text-sm text-gray-500 hover:text-brand-600 transition-colors"
>
<User className="h-4 w-4" />
{user.name}
</Link>
)}
<Button
size="sm"
variant="ghost"
onClick={logout}
aria-label="Log out"
>
<LogOut className="h-4 w-4" />
</Button>
frontend/src/middleware.ts:18
- Adding
/discovertoPUBLIC_PATHSis misleading because this middleware never blocks any routes (it always returnsNextResponse.next()). If client-side auth guarding is the real control point, consider removing/renamingPUBLIC_PATHSor implementing an actual allow/deny behavior to avoid giving a false sense of protection.
const PUBLIC_PATHS = ['/login', '/register', '/discover'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// NOTE: JWT tokens are stored in localStorage (client-side only) so we
// cannot read them in the edge middleware. Route protection is handled
// client-side via the useAuth hook. This middleware only handles
// cookie-based session tokens if added in the future.
return NextResponse.next();
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // ── Auth ────────────────────────────────────────────────────────────────────── | ||
|
|
||
| export interface User { | ||
| id: number; |
There was a problem hiding this comment.
User now requires an id, but the current /api/v1/user/me/ response (UserSerializer) only returns {name, email}. This will make user.id undefined at runtime (e.g., Navbar profile link) unless the backend is updated to include id or the type is made optional and the UI avoids relying on it.
| id: number; | |
| id?: number; |
| export interface RecipeAuthor { | ||
| id: number; | ||
| name: string; | ||
| email: string; |
There was a problem hiding this comment.
RecipeAuthor includes an email field, but the API’s embedded recipe author payload only includes id, name, and avatar. This mismatch will produce undefined emails and can cause downstream UI assumptions to fail; align the type with the API or update the serializer.
| email: string; |
| actor: { | ||
| id: number; | ||
| name: string; | ||
| avatar: string | null; | ||
| }; | ||
| recipe: { | ||
| id: number; | ||
| title: string; | ||
| } | null; |
There was a problem hiding this comment.
Notification is modeled as nested actor and recipe objects, but the notifications API currently returns flat fields like actor_name, actor_avatar, recipe_title, and recipe (id). The current type will cause runtime errors where the UI reads n.actor.id/n.recipe.title; update the type and UI mapping (or update backend response shape).
| actor: { | |
| id: number; | |
| name: string; | |
| avatar: string | null; | |
| }; | |
| recipe: { | |
| id: number; | |
| title: string; | |
| } | null; | |
| actor_name: string; | |
| actor_avatar: string | null; | |
| recipe: number | null; | |
| recipe_title: string | null; |
| user: { | ||
| id: number; | ||
| name: string; | ||
| avatar: string | null; | ||
| }; |
There was a problem hiding this comment.
RecipeComment is modeled with a nested user object, but the comments API returns author_id, author_name, and author_avatar fields. As-is, c.user.* will be undefined and comment rendering/deletion checks will break; update the type and consuming UI to match the API response.
| user: { | |
| id: number; | |
| name: string; | |
| avatar: string | null; | |
| }; | |
| author_id: number; | |
| author_name: string; | |
| author_avatar: string | null; |
| searchUsers: async (q: string, page = 1) => { | ||
| const { data } = await apiClient.get<PaginatedResponse<UserProfile>>( | ||
| `${USER_BASE}/search/`, | ||
| { params: { q, page } } |
There was a problem hiding this comment.
searchUsers sends q as the search param, but DRF SearchFilter uses search by default. Unless the backend has been customized, this will return unfiltered results; switch to { params: { search: q, page } } or align the backend filter param.
| { params: { q, page } } | |
| { params: { search: q, page } } |
| {comments.map((c: RecipeComment) => ( | ||
| <div key={c.id} className="flex items-start gap-2 group"> | ||
| <Avatar src={c.user.avatar} name={c.user.name} size="xs" /> | ||
| <div className="flex-1 bg-gray-50 rounded-lg px-3 py-2"> | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <span className="text-xs font-semibold text-gray-700">{c.user.name}</span> | ||
| <span className="text-xs text-gray-400"> | ||
| {formatDistanceToNow(new Date(c.created_at), { addSuffix: true })} | ||
| </span> | ||
| </div> | ||
| <p className="text-sm text-gray-600 mt-0.5">{c.text}</p> | ||
| </div> | ||
| {(c.user.id === currentUserId || recipeOwnerId === currentUserId) && ( | ||
| <button | ||
| onClick={() => deleteMutation.mutate(c.id)} | ||
| className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition-all" | ||
| aria-label="Delete comment" | ||
| > | ||
| <Trash2 className="h-3.5 w-3.5" /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| ))} |
There was a problem hiding this comment.
CommentThread expects each comment to have c.user.{id,name,avatar}, but the comments API returns author_id, author_name, and author_avatar. This will break rendering and permission checks (c.user.id === currentUserId); map the API response to the UI shape or update the type/serializer.
| {comments.map((c: RecipeComment) => ( | |
| <div key={c.id} className="flex items-start gap-2 group"> | |
| <Avatar src={c.user.avatar} name={c.user.name} size="xs" /> | |
| <div className="flex-1 bg-gray-50 rounded-lg px-3 py-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <span className="text-xs font-semibold text-gray-700">{c.user.name}</span> | |
| <span className="text-xs text-gray-400"> | |
| {formatDistanceToNow(new Date(c.created_at), { addSuffix: true })} | |
| </span> | |
| </div> | |
| <p className="text-sm text-gray-600 mt-0.5">{c.text}</p> | |
| </div> | |
| {(c.user.id === currentUserId || recipeOwnerId === currentUserId) && ( | |
| <button | |
| onClick={() => deleteMutation.mutate(c.id)} | |
| className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition-all" | |
| aria-label="Delete comment" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| ))} | |
| {comments.map((raw: any) => { | |
| const c = { | |
| ...raw, | |
| user: raw.user ?? { | |
| id: raw.author_id, | |
| name: raw.author_name, | |
| avatar: raw.author_avatar, | |
| }, | |
| } as RecipeComment; | |
| return ( | |
| <div key={c.id} className="flex items-start gap-2 group"> | |
| <Avatar src={c.user.avatar} name={c.user.name} size="xs" /> | |
| <div className="flex-1 bg-gray-50 rounded-lg px-3 py-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <span className="text-xs font-semibold text-gray-700">{c.user.name}</span> | |
| <span className="text-xs text-gray-400"> | |
| {formatDistanceToNow(new Date(c.created_at), { addSuffix: true })} | |
| </span> | |
| </div> | |
| <p className="text-sm text-gray-600 mt-0.5">{c.text}</p> | |
| </div> | |
| {(c.user.id === currentUserId || recipeOwnerId === currentUserId) && ( | |
| <button | |
| onClick={() => deleteMutation.mutate(c.id)} | |
| className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition-all" | |
| aria-label="Delete comment" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| })} |
| queryFn: ({ pageParam = 1 }) => feedApi.listComments(recipeId, pageParam as number), | ||
| getNextPageParam: (last, pages) => last.next ? pages.length + 1 : undefined, | ||
| initialPageParam: 1, | ||
| enabled: open, |
There was a problem hiding this comment.
The comments query is enabled whenever the thread is opened (enabled: open) even if the viewer is unauthenticated. Since the comments endpoint requires auth, opening comments on public views (e.g., /discover) will 401 and trigger a redirect via the Axios interceptor; gate the query with enabled: open && !!currentUserId and show an auth prompt instead.
| enabled: open, | |
| enabled: open && !!currentUserId, |
| <Link href={`/profile/${n.actor.id}`}> | ||
| <Avatar src={n.actor.avatar} name={n.actor.name} size="sm" /> | ||
| </Link> | ||
| <div className="flex-1 min-w-0"> | ||
| <p className="text-sm text-gray-700"> | ||
| <Link | ||
| href={`/profile/${n.actor.id}`} | ||
| className="font-semibold hover:text-brand-600" | ||
| > | ||
| {n.actor.name} | ||
| </Link> | ||
| {' '} | ||
| {kindText(n)} | ||
| </p> | ||
| <p className="text-xs text-gray-400 mt-0.5"> | ||
| {formatDistanceToNow(new Date(n.created_at), { addSuffix: true })} | ||
| </p> | ||
| </div> | ||
| <span className="mt-0.5 flex-shrink-0">{kindIcon[n.kind]}</span> |
There was a problem hiding this comment.
This page treats each notification as { actor: {id,name,avatar}, recipe: {id,title} }, but the notifications API currently returns flat actor_name/actor_avatar/recipe_title fields (and recipe as an id). Accessing n.actor.id and n.recipe?.title will throw; adjust the UI to the actual payload or update the API.
| listComments: async (recipeId: number, page = 1) => { | ||
| const { data } = await apiClient.get<PaginatedResponse<RecipeComment>>( | ||
| `${RECIPE_BASE}/recipes/${recipeId}/comments/`, | ||
| { params: { page } } | ||
| ); | ||
| return data; | ||
| }, | ||
|
|
||
| postComment: async (recipeId: number, text: string) => { | ||
| const { data } = await apiClient.post<RecipeComment>( | ||
| `${RECIPE_BASE}/recipes/${recipeId}/comments/`, | ||
| { text } | ||
| ); | ||
| return data; |
There was a problem hiding this comment.
listComments/postComment are typed to return RecipeComment, but the comments endpoint returns author_id/author_name/author_avatar (not a nested user object). This type mismatch will cascade into CommentThread expecting c.user.*; consider introducing an API-specific comment type and mapping it to the UI shape.
| listNotifications: async (page = 1) => { | ||
| const { data } = await apiClient.get<PaginatedResponse<Notification>>( | ||
| `${USER_BASE}/notifications/`, | ||
| { params: { page } } | ||
| ); | ||
| return data; |
There was a problem hiding this comment.
listNotifications/getUnreadCount are typed against the new Notification interface (nested actor/recipe), but the current notifications API returns flat actor_name/actor_avatar/recipe_title fields (and recipe as an id). Either adjust the types/mapping here (e.g., transform response to the UI shape) or update the backend serializer to match.
Summary
/feed): recipes from followed users, infinite scroll viaIntersectionObserver, skeleton loaders, empty state with CTA to Discover/discover): trending recipes (public, no auth), search bar, 3-column grid, infinite scroll/profile/[id]): avatar, bio, website, location, follower/following/recipe counts; tabs for Recipes / Followers / Following with infinite scroll on each; FollowButton with optimistic updates/notifications): paginated list with relative timestamps, mark-all-read button, kind icons (follow/like/comment)RecipeCardSkeletonandUserCardSkeletonfor async loading statesdate-fnsadded for relative timestampsTest plan
/feed— shows recipes from followed users; infinite scroll loads next page/feedempty state — CTA links to /discover/discover— loads without auth; search filters results; infinite scroll works/profile/[id]— loads own + other profiles; FollowButton toggles correctly; tabs switch content/notifications— mark-all-read clears badge🤖 Generated with Claude Code