Skip to content

feat: social frontend — feed, discover, profiles, likes, comments, notifications (#27)#28

Merged
sadykovIsmail merged 2 commits intomainfrom
feat/social-frontend-issue-27
Mar 31, 2026
Merged

feat: social frontend — feed, discover, profiles, likes, comments, notifications (#27)#28
sadykovIsmail merged 2 commits intomainfrom
feat/social-frontend-issue-27

Conversation

@sadykovIsmail
Copy link
Copy Markdown
Owner

Summary

  • Feed page (/feed): recipes from followed users, infinite scroll via IntersectionObserver, skeleton loaders, empty state with CTA to Discover
  • Discover page (/discover): trending recipes (public, no auth), search bar, 3-column grid, infinite scroll
  • Profile page (/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 page (/notifications): paginated list with relative timestamps, mark-all-read button, kind icons (follow/like/comment)
  • NotificationBell: polls unread count every 30s, badge in Navbar
  • LikeButton: optimistic update with scale animation, rollback on error
  • FollowButton: optimistic update on follower count, rollback on error
  • CommentThread: lazy-loaded (opens on click), infinite scroll, post/delete with auth guard
  • RecipeCardSocial: replaces RecipeCard on social views — shows author avatar + link, like/comment bar, owner edit/delete
  • Avatar: renders image or fallback initials
  • Skeleton: RecipeCardSkeleton and UserCardSkeleton for async loading states
  • date-fns added for relative timestamps

Test plan

  • /feed — shows recipes from followed users; infinite scroll loads next page
  • /feed empty 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
  • LikeButton — optimistic heart toggle; count updates immediately; rollback on network error
  • CommentThread — opens on click; posts and deletes; loads more
  • NotificationBell — badge shows unread count; refreshes every 30s
  • /notifications — mark-all-read clears badge
  • Navbar — Feed, Discover, profile link all render correctly

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings March 31, 2026 00:07
Copy link
Copy Markdown
Owner Author

@sadykovIsmail sadykovIsmail left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Social Frontend

Architecture & Patterns ✅

API layer splitsocialApi (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 patternsuseInfiniteQuery 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 updatesFollowButton 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 pollingrefetchInterval: 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 loadersRecipeCardSkeleton mirrors the card layout exactly, preventing layout shift when data loads. Good.

Potential Issues 🔍

  1. CommentThread imports date-fnsdate-fns has been added to package.json but not yet run through npm install. The Dockerfile will handle this, but note that the package-lock.json will be stale until npm install runs inside Docker. This is expected behavior.

  2. 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/:id and 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.

  3. use(params) in Profile page — Uses React 19's use() 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, params is a plain object, not a Promise — using use() will cause a runtime error. Change to const { id } = params; and accept params: { id: string } directly.

  4. Discover page main container — The (dashboard)/layout.tsx already wraps children in a max-w-7xl mx-auto px-4 container. The Discover page adds its own max-w-4xl mx-auto px-4 which double-wraps the padding. Remove the outer padding from the page component and rely on the layout's padding.

  5. Missing key prop stability — In CommentThread, [...Array(3)].map((_, i) => ...) uses index as key which is fine for static skeleton arrays. For actual data, the comment.id key is correct.

Security ✅

  • Comment delete button is guarded by c.user.id === currentUserId || recipeOwnerId === currentUserId on 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 blockinguse(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: change params: 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>
@sadykovIsmail sadykovIsmail merged commit 897a468 into main Mar 31, 2026
3 of 7 checks passed
@sadykovIsmail sadykovIsmail deleted the feat/social-frontend-issue-27 branch March 31, 2026 00:08
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /discover through 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 when user is 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 /discover to PUBLIC_PATHS is misleading because this middleware never blocks any routes (it always returns NextResponse.next()). If client-side auth guarding is the real control point, consider removing/renaming PUBLIC_PATHS or 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;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
id: number;
id?: number;

Copilot uses AI. Check for mistakes.
export interface RecipeAuthor {
id: number;
name: string;
email: string;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
email: string;

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +59
actor: {
id: number;
name: string;
avatar: string | null;
};
recipe: {
id: number;
title: string;
} | null;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +123
user: {
id: number;
name: string;
avatar: string | null;
};
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
user: {
id: number;
name: string;
avatar: string | null;
};
author_id: number;
author_name: string;
author_avatar: string | null;

Copilot uses AI. Check for mistakes.
searchUsers: async (q: string, page = 1) => {
const { data } = await apiClient.get<PaginatedResponse<UserProfile>>(
`${USER_BASE}/search/`,
{ params: { q, page } }
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
{ params: { q, page } }
{ params: { search: q, page } }

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +121
{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>
))}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
{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>
);
})}

Copilot uses AI. Check for mistakes.
queryFn: ({ pageParam = 1 }) => feedApi.listComments(recipeId, pageParam as number),
getNextPageParam: (last, pages) => last.next ? pages.length + 1 : undefined,
initialPageParam: 1,
enabled: open,
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
enabled: open,
enabled: open && !!currentUserId,

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +113
<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>
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +56
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;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +79
listNotifications: async (page = 1) => {
const { data } = await apiClient.get<PaginatedResponse<Notification>>(
`${USER_BASE}/notifications/`,
{ params: { page } }
);
return data;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants