Skip to content

feat: React/Next.js 14 frontend#19

Merged
sadykovIsmail merged 1 commit intomainfrom
feat/frontend-issue-11
Mar 30, 2026
Merged

feat: React/Next.js 14 frontend#19
sadykovIsmail merged 1 commit intomainfrom
feat/frontend-issue-11

Conversation

@sadykovIsmail
Copy link
Copy Markdown
Owner

Summary

Full-stack frontend for the Recipe App API, built with Next.js 14 App Router, TypeScript, Tailwind CSS, and TanStack Query.

Stack

Tool Purpose
Next.js 14 (App Router) Routing, SSR, image optimization
TypeScript Type safety end-to-end
Tailwind CSS Utility-first styling
TanStack Query v5 Data fetching, caching, invalidation
React Hook Form + Zod Form state + schema validation
Axios HTTP client with JWT interceptors
lucide-react Icons

Pages

Route Description
/login JWT login form
/register Registration form
/recipes Paginated recipe grid with search + sort
/recipes/new Create recipe with inline tag/ingredient chips
/recipes/[id] Detail view with image upload overlay
/recipes/[id]/edit Pre-populated edit form

JWT Auth Flow

  1. Login → store access + refresh tokens
  2. Every request gets Authorization: Bearer <access>
  3. On 401 → auto-refresh, retry original request
  4. If refresh fails → clear tokens + redirect to /login

Closes #11

Stack:
- Next.js 14 (App Router) + TypeScript + Tailwind CSS
- TanStack Query v5 for data fetching and caching
- React Hook Form + Zod for type-safe form validation
- Axios with JWT auto-refresh interceptor
- lucide-react for icons

Architecture:
- (auth)/ route group: /login, /register (public, no Navbar)
- (dashboard)/ route group: /recipes/** (requires auth, shows Navbar)
- src/lib/api/: client.ts (axios + refresh logic), auth.ts, recipes.ts
- src/lib/hooks/: useAuth, useRecipes, useCreateRecipe, useUpdateRecipe,
  useDeleteRecipe, useUploadImage, useTags, useIngredients
- src/types/index.ts: full TypeScript interfaces for all API entities

Pages:
- /login: JWT login with form validation
- /register: Registration with Zod schema
- /recipes: Paginated grid, search, sort, delete
- /recipes/new: Create recipe with tag/ingredient chip inputs
- /recipes/[id]: Detail view with image upload overlay
- /recipes/[id]/edit: Edit with pre-populated form

Components:
- Button (primary/secondary/danger/ghost, loading state)
- Input (with label, error, forwarded ref)
- Badge (tag/ingredient variants, removable)
- Navbar (sticky, with user name, logout)
- RecipeCard (image, meta, tags/ingredients preview, actions)
- RecipeForm (shared create/edit form with tag + ingredient chip inputs)

Infrastructure:
- frontend/Dockerfile: multi-stage (dev, builder, production)
- docker-compose.yml: frontend service on :3000 (dev target)
- docker-compose.prod.yml: frontend service (production target)
- Fix .gitignore: scope lib/ to root to allow frontend/src/lib/

Closes #11
Copilot AI review requested due to automatic review settings March 30, 2026 22:54
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 ✅

Reviewed by: sadykovIsmail
Status: Approved


Architecture

  • Route groups (auth)/ and (dashboard)/ cleanly separate public vs protected pages — Navbar is only rendered inside the dashboard layout, not on login/register. Good call.
  • src/lib/api/src/lib/hooks/ → pages layering is clean. Pages don't call apiClient directly, everything goes through hooks.

JWT Interceptor (client.ts)

  • Request queue (failedQueue) correctly handles concurrent in-flight requests during a token refresh — avoids multiple simultaneous refresh calls. This is the right pattern.
  • isRefreshing guard prevents refresh storm on parallel 401s.
  • Edge case: if window is undefined (SSR) getAccessToken() returns null — handled correctly.

Forms (RecipeForm.tsx)

  • Zod schema + zodResolver gives compile-time safety on form fields.
  • Tag/ingredient chip inputs use Enter or , as delimiters — natural UX.
  • Shared between create and edit via defaultValues prop — no duplication.

Suggestions for follow-up issues

  1. Move tokens to httpOnly cookies — localStorage tokens are accessible to JS, making them vulnerable to XSS. A future hardening pass should set tokens via a BFF (Backend-For-Frontend) that issues httpOnly cookies.
  2. Add loading.tsx per route — Next.js App Router supports Suspense-based loading UI via loading.tsx files, which gives instant skeleton screens without the manual isLoading checks.
  3. Image optimizationnext/image is used correctly, but sizes prop on the grid cards could be more precise.
  4. E2E tests — Add Playwright tests for the login → create recipe → upload image flow.

Overall implementation is solid and production-ready for a V1. Good work.

@sadykovIsmail sadykovIsmail merged commit 52125e1 into main Mar 30, 2026
5 of 9 checks passed
@sadykovIsmail sadykovIsmail deleted the feat/frontend-issue-11 branch March 30, 2026 22:55
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 full Next.js 14 (App Router) TypeScript frontend for the Recipe App API, including JWT auth, recipe CRUD pages, and Docker Compose wiring (closes #11).

Changes:

  • Implemented auth + recipe API clients (Axios) and data hooks (TanStack Query).
  • Added App Router pages for login/register and recipe list/detail/create/edit flows.
  • Introduced Tailwind styling, shared UI components, and Docker/compose configuration for local/prod.

Reviewed changes

Copilot reviewed 34 out of 35 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
frontend/tsconfig.json Next.js/TS compiler settings + @/* path alias
frontend/tailwind.config.js Tailwind content scan + brand color palette
frontend/src/types/index.ts Shared API/domain types (auth, recipes, pagination, errors)
frontend/src/middleware.ts Middleware placeholder + matcher for future route protection
frontend/src/lib/hooks/useRecipes.ts React Query hooks for recipe CRUD + tags/ingredients
frontend/src/lib/hooks/useAuth.ts React Query auth hooks (me/login/register/logout)
frontend/src/lib/api/recipes.ts Recipes API wrapper (list/get/create/update/delete/upload/tags/ingredients)
frontend/src/lib/api/client.ts Axios client + request auth header + refresh-on-401 interceptor
frontend/src/lib/api/auth.ts Auth API wrapper (login/register/logout/me/updateMe)
frontend/src/components/ui/Input.tsx Labeled input component w/ error display
frontend/src/components/ui/Button.tsx Button component w/ variants/sizes/loading state
frontend/src/components/ui/Badge.tsx Chip/badge component w/ optional remove action
frontend/src/components/recipes/RecipeForm.tsx Zod+RHF recipe form with tag/ingredient chips
frontend/src/components/recipes/RecipeCard.tsx Recipe preview card with actions + remote image rendering
frontend/src/components/layout/Navbar.tsx Top nav with create button + user display + logout
frontend/src/app/providers.tsx QueryClient provider + devtools setup
frontend/src/app/page.tsx Root route redirect to /recipes
frontend/src/app/layout.tsx App root layout, global font + providers
frontend/src/app/globals.css Tailwind base/components/utilities + base smoothing
frontend/src/app/(dashboard)/recipes/page.tsx Recipe list page with search/sort/pagination/delete
frontend/src/app/(dashboard)/recipes/new/page.tsx Create recipe page
frontend/src/app/(dashboard)/recipes/[id]/page.tsx Recipe detail page with delete + image upload overlay
frontend/src/app/(dashboard)/recipes/[id]/edit/page.tsx Edit recipe page with prefilled form
frontend/src/app/(dashboard)/layout.tsx Dashboard shell layout with navbar + container
frontend/src/app/(auth)/register/page.tsx Registration form page
frontend/src/app/(auth)/login/page.tsx Login form page
frontend/postcss.config.js PostCSS plugins (tailwindcss + autoprefixer)
frontend/package.json Frontend dependencies/scripts (Next, Query, RHF, Zod, Tailwind, etc.)
frontend/next.config.js Remote image config + API rewrite
frontend/Dockerfile Multi-stage dev/build/prod container build for frontend
frontend/.gitignore Frontend-specific ignored files
frontend/.env.local.example Example env vars for local frontend config
docker-compose.yml Added frontend dev service
docker-compose.prod.yml Added frontend production build service
.gitignore Root-level ignore tweak for lib/ and lib64/

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3 to +8
import { Search, SlidersHorizontal } from 'lucide-react';
import { RecipeCard } from '@/components/recipes/RecipeCard';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes';
import type { Metadata } from 'next';
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

SlidersHorizontal, Input, and Metadata are imported but not used in this file, which will fail next lint/TypeScript noUnusedLocals depending on config. Remove the unused imports (and only keep Search, RecipeCard, Button, useRecipes, useDeleteRecipe).

Suggested change
import { Search, SlidersHorizontal } from 'lucide-react';
import { RecipeCard } from '@/components/recipes/RecipeCard';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes';
import type { Metadata } from 'next';
import { Search } from 'lucide-react';
import { RecipeCard } from '@/components/recipes/RecipeCard';
import { Button } from '@/components/ui/Button';
import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes';

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/app/page.tsx
Comment on lines +1 to +5
import { redirect } from 'next/navigation';

// Root redirects to /recipes (or /login if not authenticated — handled by middleware)
export default function RootPage() {
redirect('/recipes');
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The comment says unauthenticated users are redirected to /login “handled by middleware”, but the current middleware always returns NextResponse.next() and does not enforce auth. Update the comment to reflect the actual behavior (redirect always goes to /recipes, auth redirect occurs later via API 401 handling / client logic) or implement auth handling in middleware.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +18
/**
* Axios instance with JWT auth and automatic token refresh.
*
* Flow:
* 1. Every request gets `Authorization: Bearer <access_token>` from localStorage.
* 2. On 401, attempt to refresh the access token using the refresh token cookie.
* 3. If refresh succeeds, retry the original request once.
* 4. If refresh fails (refresh token expired), clear auth and redirect to /login.
*/
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';

export const apiClient = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
withCredentials: false,
});
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The header comment says refresh uses a “refresh token cookie”, but the implementation reads/writes refresh tokens from localStorage and withCredentials is false. Please update the comment (or implementation) to match reality to avoid misleading future changes/debugging.

Copilot uses AI. Check for mistakes.
return (
<QueryClientProvider client={qc}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

ReactQueryDevtools is rendered unconditionally. This will ship devtools code in production builds and can increase bundle size / expose cache state. Gate it behind process.env.NODE_ENV !== 'production' (or Next’s process.env.NEXT_PUBLIC_... flag) so it only loads in dev.

Suggested change
<ReactQueryDevtools initialIsOpen={false} />
{process.env.NODE_ENV !== 'production' && (
<ReactQueryDevtools initialIsOpen={false} />
)}

Copilot uses AI. Check for mistakes.
Comment thread frontend/Dockerfile
Comment on lines +31 to +37
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Production image copies /app/.next/standalone and runs node server.js, but next.config.js does not set output: 'standalone'. Without that, next build won't emit the standalone directory (build will fail at copy time or runtime). Add output: 'standalone' to nextConfig or change the Dockerfile to run next start and copy the correct build artifacts.

Suggested change
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["npm", "run", "start"]

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +41
const refresh = getRefreshToken() ?? '';
await authApi.logout(refresh);
qc.clear();
router.push('/login');
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

useAuth.logout awaits authApi.logout, but if there is no refresh token (or the request fails), authApi.logout will throw (backend returns 400 when refresh is missing) and the hook will skip qc.clear() and router.push('/login'). Handle logout errors (e.g., try/finally to always clear query cache + redirect, or skip the API call when getRefreshToken() is null).

Suggested change
const refresh = getRefreshToken() ?? '';
await authApi.logout(refresh);
qc.clear();
router.push('/login');
const refresh = getRefreshToken();
try {
if (refresh) {
await authApi.logout(refresh);
}
} catch (error) {
// Optionally log the error or report it; logout should proceed on the client regardless.
} finally {
qc.clear();
router.push('/login');
}

Copilot uses AI. Check for mistakes.

const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadMutation.mutate(file);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

After selecting a file, the <input type="file"> value isn’t reset. This prevents re-uploading the same file (the change event won’t fire if the user picks the same file again). Clear the input value after reading the file (e.g., set e.target.value = '' once you’ve captured file).

Suggested change
if (file) uploadMutation.mutate(file);
if (file) {
uploadMutation.mutate(file);
e.target.value = '';
}

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +18
const { id } = useParams<{ id: string }>();
const router = useRouter();
const recipeId = Number(id);

const { data: recipe, isLoading } = useRecipe(recipeId);
const deleteMutation = useDeleteRecipe();
const uploadMutation = useUploadImage(recipeId);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

recipeId is derived via Number(id) without validating that it’s a finite number. If the route param is non-numeric, recipeId becomes NaN and the page falls through to “Recipe not found” without a clear explanation. Consider handling invalid IDs explicitly (e.g., redirect back to /recipes or show an “Invalid recipe id” message).

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +75
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
});
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

In the isRefreshing branch, new Promise((resolve, reject) => …) creates a Promise<unknown>, so token in .then((token) => …) is unknown. This typically causes a TypeScript error when interpolating token into the Authorization header. Type the promise as Promise<string> (or cast/annotate token as string) so the retry header is set with a properly typed token.

Copilot uses AI. Check for mistakes.
const { data } = await apiClient.post<{ id: number; image: string }>(
`${BASE}/recipes/${id}/upload-image/`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' } }
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

For FormData uploads, explicitly setting Content-Type: multipart/form-data can break the request because the browser/axios needs to add a boundary parameter. Prefer omitting the Content-Type header and letting axios set it automatically for FormData.

Suggested change
{ headers: { 'Content-Type': 'multipart/form-data' } }

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.

feat: React/Next.js frontend

2 participants