দেশি বুক — Context API + Axios দিয়ে সম্পূর্ণ Facebook Clone প্রজেক্ট
হাতে-কলমে বানাবো "দেশি বুক" — একটি সম্পূর্ণ ফাংশনাল সোশ্যাল ফিড অ্যাপ। থাকছে Context API + useReducer দিয়ে State Management, Axios Interceptor দিয়ে JWT Auto-Refresh, প্রতিটা ফিচারের পর বাস্তব ডেভেলপারদের মতো Git commit/push workflow, আর সবচেয়ে গুরুত্বপূর্ণ — আসল ব্যাকএন্ড সার্ভার সরাসরি চালিয়ে, প্রতিটা endpoint টেস্ট করে যাচাই করা কোড। যিনি জীবনে কখনো Context API বা Git ব্যবহার করেননি, তিনিও এই গাইড অনুসরণ করে সম্পূর্ণ প্রজেক্টটা বানাতে পারবেন।
দেশি বুক — একটি সম্পূর্ণ হাতে-কলমে প্রজেক্ট (Context API + Axios + Git)
আমরা বানাবো দেশি বুক (Deshi Book) — একটা Facebook-এর মতো সোশ্যাল ফিড অ্যাপ। এই টিউটোরিয়ালে তিনটা জিনিস একসাথে শিখবেন:
- React আর্কিটেকচার — Context API (Provider Pattern), Custom Hooks,
useReducer, Service Layer, Container/Presentational Component প্যাটার্ন। - Axios Advanced — Request/Response Interceptor দিয়ে JWT access + refresh token স্বয়ংক্রিয়ভাবে renew করা।
- Git Workflow — বাস্তব ডেভেলপারদের মতো ফিচার-বাই-ফিচার commit, আর GitHub-এ push।
আর সবচেয়ে গুরুত্বপূর্ণ — এই প্রজেক্টের প্রতিটা কোড আসল ব্যাকএন্ড সার্ভার সরাসরি চালিয়ে, curl দিয়ে প্রতিটা endpoint টেস্ট করে যাচাই করা হয়েছে, শুধু API ডকুমেন্টেশন পড়ে অনুমান করে না। এই প্রক্রিয়ায় ডকুমেন্টেশনে যা লেখা ছিল তার সাথে বাস্তব সার্ভারের আচরণে কয়েকটা গুরুত্বপূর্ণ অমিল পাওয়া গেছে — সেগুলো সেকশন ৪-এ বিস্তারিত দেখব। এটাই হাতে-কলমে শেখার আসল শক্তি: docs বিশ্বাস করার আগে নিজে টেস্ট করে দেখুন।
ব্যবহার করব:
- React Context API —
AuthProviderদিয়ে গ্লোবাল auth state। - Axios — Interceptor দিয়ে token attach আর auto-refresh।
useReducer— Profile-এর মতো জটিল, multi-field state-এর জন্য।- Custom Hooks —
useAuth,useAxios,usePosts,useProfile। - React Router — Protected Route pattern, dynamic params।
- Tailwind CSS — আধুনিক, রেসপন্সিভ UI।
- Git — ফিচার-বাই-ফিচার commit, Conventional Commits, GitHub push।
১. প্রজেক্ট পরিচিতি — কী বানাচ্ছি, কেন এই Approach?
১.১ ফিচার লিস্ট
- Auth — Register, Login, JWT access + refresh token।
- News Feed — টেক্সট/ছবি পোস্ট তৈরি, লাইক, কমেন্ট, এডিট, ডিলিট।
- Profile — অ্যাভাটার আপলোড, নাম এডিট, নিজের পোস্ট লিস্ট।
১.২ কী কী শিখবেন
- React Context API ও Provider Pattern দিয়ে গ্লোবাল state — কবে Context আর কবে Redux/RTK Query বেছে নেবেন, তার একটা practical উদাহরণ (আমাদের অন্য প্রজেক্ট "দেশি দোকান" RTK Query ব্যবহার করে — সেটার সাথে তুলনা করলে পার্থক্যটা স্পষ্ট হবে)।
- Axios Interceptor — request-এর আগে টোকেন বসানো, 401 পেলে automatic token refresh।
useReducer— কবে সাধারণuseStateযথেষ্ট না, কবে reducer দরকার।- Custom Hooks — যুক্তি (logic) আলাদা করে পুনঃব্যবহারযোগ্য করা।
- Service Layer Pattern — সব API কল এক জায়গায় সংগঠিত রাখা।
- Git Workflow —
init, ফিচার-বাই-ফিচারcommit, GitHub-এpush। - বাস্তব API টেস্ট করার অভ্যাস — ডকুমেন্টেশনের উদাহরণ সবসময় সঠিক না-ও হতে পারে; curl দিয়ে নিজে verify করার গুরুত্ব।
এই টিউটোরিয়ালটা সম্পূর্ণ বিগিনারদের জন্য লেখা। জীবনে কখনো Context API, Axios Interceptor, বা Git ব্যবহার না করে থাকলেও সমস্যা নেই।
১.৩ Git ওয়ার্কফ্লো
আমাদের দেশি দোকান প্রজেক্টের মতোই, এখানেও প্রতিটা ফিচার শেষে একটা 🔧 Git Commit বক্স থাকবে, Conventional Commits প্রিফিক্স (feat:, chore:, style:, docs:) সহ। ভিন্নতা একটাই — সার্ভারের জন্য আলাদা কোনো git history বানাতে হবে না; ব্যাকএন্ড (FaceHook API) আগে থেকেই রেডি একটা প্রজেক্ট হিসেবে দেওয়া থাকবে, আমরা শুধু সেটা ক্লোন করে চালাব। Git workflow শুধু আমাদের ক্লায়েন্ট কোডের জন্য।
২. প্রজেক্ট Setup
২.১ Vite দিয়ে Frontend তৈরি করা
npm create vite@latest deshi-book -- --template react
cd deshi-book
npm install২.২ Git দিয়ে Version Control শুরু করা
git init
git branch -M main
git config user.name "আপনার নাম"
git config user.email "আপনার ইমেইল"
git add .
git commit -m "chore: scaffold project with vite and react"
git config-এ--globalফ্ল্যাগ দিলে এই সেটিং কম্পিউটারের সব প্রজেক্টের জন্য একবারে সেট হয়ে যায়।
২.৩ Tailwind CSS
npm install tailwindcss @tailwindcss/vitevite.config.js:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});src/index.css:
@import "tailwindcss";Tailwind যাচাইয়ের জন্য src/App.jsx সাময়িকভাবে সিম্পল করে দিন:
// src/App.jsx
function App() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<h1 className="text-3xl font-bold text-blue-600">
Tailwind is working! 🎉
</h1>
</div>
);
}
export default App;অপ্রয়োজনীয় ফাইল মুছুন:
rm -rf src/App.css src/assets public/icons.svg🔧 Git Commit
git add . git commit -m "style: add tailwind css"
২.৪ Axios আর React Router ইনস্টল করা
npm install axios react-router🚨
react-router-domনা, শুধুreact-routerReact Router v8-এ
BrowserRouter,Routes,Route,Link,useNavigate,useParams,Navigateসবই সরাসরি"react-router"থেকে import হয় —react-router-domআর মূলধারায় নেই।
২.৫ Folder Structure
src/
├── actions/
│ └── index.js ← reducer action type constants
├── api/
│ └── api.js ← axios instance (baseURL সহ)
├── context/
│ └── AuthContext.jsx ← Provider Pattern দিয়ে গ্লোবাল auth state
├── hooks/
│ ├── useAuth.js
│ ├── useAxios.js ← interceptor hook
│ ├── usePosts.js
│ └── useProfile.js
├── reducers/
│ └── profileReducer.js
├── services/
│ ├── authService.js
│ ├── postService.js
│ └── profileService.js
├── components/
│ ├── LoginForm.jsx
│ ├── RegisterForm.jsx
│ ├── ProtectedRoute.jsx
│ ├── Navbar.jsx
│ ├── feed/
│ │ ├── CreatePostForm.jsx
│ │ ├── PostCard.jsx
│ │ └── PostFeed.jsx
│ └── profile/
│ ├── ProfileImage.jsx
│ └── ProfileNameEditor.jsx
├── pages/
│ ├── FeedPage.jsx
│ └── ProfilePage.jsx
├── App.jsx
└── main.jsxmkdir -p src/actions src/api src/context src/hooks src/reducers src/services src/components/feed src/components/profile src/pages২.৬ .env বনাম .env.example
# .env.example
VITE_API_BASE_URL=http://localhost:3000cp .env.example .env.gitignore-এ যোগ করুন:
# Environment variables
.envsrc/api/api.js-এ একটা কেন্দ্রীয় axios instance বানান:
// src/api/api.js
import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
});
export default api;🔧 Git Commit
git add . git commit -m "chore: setup axios, react-router and folder structure"
৩. ব্যাকএন্ড (FaceHook API) সেটআপ
এই ক্লায়েন্ট একটা রেডিমেড ব্যাকএন্ড (Express + json-server, JWT auth, image upload সহ) — FaceHook API — এর সাথে কাজ করার জন্য বানানো। ব্যাকএন্ড আলাদা প্রজেক্ট, আলাদা টার্মিনালে চালাতে হবে:
cd facebook-clone-server
npm install
npm startডিফল্টভাবে এটা
http://localhost:3000-এ চলবে। এই সার্ভারে সেকশন ৪.৪-এ বর্ণিত একটা এক-লাইনের বাগ-ফিক্স প্রয়োজন (register-এর পর একই সেশনে login fail করার সমস্যা) — সেটা এখনই না, সেকশন ৪.৪-এ বিস্তারিত দেখুন এবং লাগিয়ে নিন।
৩.১ Endpoints Reference
Table 3.1: FaceHook API-র সব endpoint
| ফিচার | Method | Endpoint | Auth লাগে? | Body/Params |
|---|---|---|---|---|
| রেজিস্ট্রেশন | POST | /auth/register | না | { email, password, firstName, lastName } |
| লগইন | POST | /auth/login | না | { email, password } |
| টোকেন রিফ্রেশ | POST | /auth/refresh-token | না | { refreshToken } |
| সব পোস্ট | GET | /posts | হ্যাঁ | — |
| একটা পোস্ট | GET | /posts/:id | হ্যাঁ | — |
| নতুন পোস্ট | POST | /posts | হ্যাঁ | FormData: postType, content, image? |
| পোস্ট এডিট | PATCH | /posts/:id | হ্যাঁ | FormData: content |
| পোস্ট ডিলিট | DELETE | /posts/:id | হ্যাঁ | — |
| লাইক টগল | PATCH | /posts/:id/like | হ্যাঁ | — |
| কমেন্ট যোগ | PATCH | /posts/:id/comment | হ্যাঁ | { comment } |
| কমেন্ট ডিলিট | DELETE | /posts/:id/comment/:commentId | হ্যাঁ | — |
| প্রোফাইল দেখা | GET | /profile/:id | হ্যাঁ | — |
| প্রোফাইল আপডেট | PATCH | /profile/:id | হ্যাঁ | { firstname, lastName } |
| অ্যাভাটার আপলোড | POST | /profile/:id/avatar | হ্যাঁ | FormData: avatar |
Auth লাগে এমন প্রতিটা request-এ
Authorization: Bearer <token>হেডার পাঠাতে হবে — আমরা এটা ম্যানুয়ালি প্রতিবার লিখব না, সেকশন ৯-এ একটা Axios Interceptor দিয়ে স্বয়ংক্রিয় করে দেব।
৪. সার্ভার টেস্ট করে যা পাওয়া গেল
এই সেকশনটাই এই টিউটোরিয়ালের সবচেয়ে গুরুত্বপূর্ণ শিক্ষা। ব্যাকএন্ডের API ডকুমেন্টেশন পড়ে সরাসরি ক্লায়েন্ট কোড লেখা শুরু করার বদলে, সার্ভার সত্যিই চালিয়ে, curl দিয়ে প্রতিটা endpoint-এ সরাসরি hit করে response verify করা হয়েছে। এতে ডকুমেন্টেশনের উদাহরণ আর বাস্তব সার্ভারের আচরণে চারটা গুরুত্বপূর্ণ অমিল পাওয়া গেছে।
৪.১ Post/Comment-এর author-এ firstname না, name
ডকুমেন্টেশনের উদাহরণ response-এ author দেখানো হয়েছিল { email, firstname, lastname, id } আকারে। কিন্তু সরাসরি টেস্ট করে দেখা গেল:
curl -X POST http://localhost:3000/posts \
-H "Authorization: Bearer <token>" \
-F "postType=text" -F "content=Verification post content"{
"author": {
"id": "773a8d66-fddc-4eee-8447-7272f664bb44",
"name": "Verify Two",
"avatar": null
}
}আসল shape { id, name, avatar } — একটা মিলিত name স্ট্রিং, আলাদা firstname/lastname না। মন্তব্যের author-ও একই শেপ। এই জন্যই আমাদের PostCard.jsx-এ (সেকশন ১৮) post.author?.name ব্যবহার করব, firstname না।
৪.২ Like endpoint সম্পূর্ণ post ফেরত দেয় না
curl -X PATCH http://localhost:3000/posts/<id>/like -H "Authorization: Bearer <token>"{ "message": "Post Liked Successfully", "likeCount": 1 }শুধু message আর likeCount — সম্পূর্ণ post object না। যদি ক্লায়েন্ট এই response দিয়ে সরাসরি পুরো post replace করত, তাহলে post-এর content, author, comments সব হারিয়ে যেত। সমাধান: ক্লায়েন্ট নিজেই optimistically likes অ্যারে আপডেট করবে (দেখুন সেকশন ১৮)।
৪.৩ Comment endpoint পুরো post ফেরত দেয় না, শুধু comments অ্যারে
curl -X PATCH http://localhost:3000/posts/<id>/comment \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"comment":"Nice post!"}'{
"message": "Comment Added Successfully",
"commentCount": 1,
"comments": [{ "id": "...", "comment": "Nice post!", "author": { "...": "..." } }]
}{ message, commentCount, comments } — comments অ্যারেটা আছে (কাজে লাগবে), কিন্তু বাকি post fields (content, author, likes) নেই। সমাধান: শুধু comments অংশটুকু বিদ্যমান post-এর সাথে merge করা।
৪.৪ একটা আসল সার্ভার বাগ: Register-এর পরপরই Login করলে fail করে
এটা সবচেয়ে গুরুত্বপূর্ণ আবিষ্কার। একই সার্ভার সেশনে, register করার পরপরই সেই ইউজার দিয়ে login করার চেষ্টা করলে:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" \
-d '{"email":"sametest@mail.com","password":"test1234","firstName":"Same","lastName":"Session"}'
# ✅ সফল, token সহ user object ফেরত পাওয়া যায়
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"sametest@mail.com","password":"test1234"}'
# ❌ { "error": "data and hash arguments required" }কারণ: ব্যাকএন্ডের services/user.serivce.js-এর register ফাংশন নতুন user object-টা ডাটাবেজে push করার পর, response পাঠানোর আগে সেই একই object reference-এর password ফিল্ড delete করে দেয় — যেটা ভুলবশত in-memory ডাটাবেজেও password মুছে দেয় (যদিও ডিস্কে লেখা আগের কপিটা ঠিকই থাকে)। ফলে সেই একই সার্ভার প্রসেস চলাকালীন, সেই ইউজারের জন্য login করলে bcrypt password compare করতে গিয়ে ব্যর্থ হয় — সার্ভার রিস্টার্ট করলে আবার ঠিকমতো কাজ করে।
এক-লাইনের ফিক্স: একই ফাইলের login ফাংশন এই ভুলটা করে না — Object.assign({}, user) দিয়ে আগে ক্লোন করে, তারপর ক্লোনের password মোছে, আসল রেকর্ড অক্ষত থাকে। register-এও ঠিক এই একই প্যাটার্ন বসিয়ে দিলেই বাগ চিরতরে চলে যায়:
// server/services/user.serivce.js — register() ফাংশনের ভেতরে
db.get("users").push(newUser).write();
const token = getNewTokens(newUser);
// Object.assign দিয়ে ক্লোন করে তারপর password মুছছি — সরাসরি newUser থেকে
// মুছলে সেটা db array-তে push করা ওই একই object reference থেকেও মুছে যেত।
let userObj = Object.assign({}, newUser);
delete userObj.password;
return {
user: userObj,
token,
};ফিক্সের পর যাচাই:
# ফিক্সের পর, একই সার্ভার সেশনে register + login দুটোই সফল হয়:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" \
-d '{"email":"bugfixtest@mail.com","password":"test1234"}'
# ✅ { "user": {...}, "token": { "token": "...", "refreshToken": "..." } }💡 এই সেকশনের শিক্ষা
API ডকুমেন্টেশন (Postman collection, README ইত্যাদি) সবসময় বাস্তব কোডের সাথে ১০০% মিলবে, এমন গ্যারান্টি নেই — ডকুমেন্টেশন পুরনো হয়ে যেতে পারে, বা উদাহরণ response ভুলভাবে লেখা হতে পারে। যেকোনো নতুন backend-এর সাথে কাজ শুরু করার আগে, অন্তত মূল endpoint গুলো
curlবা Postman দিয়ে নিজে একবার hit করে response shape যাচাই করে নেওয়া একটা গুরুত্বপূর্ণ অভ্যাস — এতে পরে ডিবাগিং-এ অনেক সময় বাঁচে। আর কখনো কখনো, বাগটা আপনার নিজের ক্লায়েন্ট কোডে না, backend-এই থাকে — root cause পুরোপুরি বুঝে গেলে, এক-লাইনের ফিক্সও যথেষ্ট হতে পারে।
৪.৫ একটা সেকেন্ড আসল বাগ: নিজের কোডেও ভুল হয় — Interceptor Attach করতে ভুলে যাওয়া
সার্ভারের বাগ ছাড়াও, প্রথমবার এই প্রজেক্ট বানানোর সময় নিজের ক্লায়েন্ট কোডেই একটা মারাত্মক ভুল হয়েছিল যেটা শুধু ব্যবহার করে দেখেই ধরা পড়ে: সেকশন ৯-এ useAxios.js হুক লেখা হয়েছিল, কিন্তু App.jsx-এর কোথাও এই হুকটা আসলে call-ই করা হয়নি! ফলাফল — request interceptor কখনো attach হয়নি, কোনো request-এই Authorization header যায়নি, আর প্রতিটা protected endpoint (/posts, /profile/:id ইত্যাদি) সবসময় { "error": "Please Provide Valid Token" } দিয়ে reject করেছে — লগইন সফল হওয়া সত্ত্বেও।
এই বাগটা বিশেষভাবে বিপজ্জনক কারণ কোনো build error বা lint warning দেয় না — useAxios ফাইলটা সঠিকভাবে লেখা, শুধু কোথাও import+call করা হয়নি। এটা ধরা পড়ে শুধুমাত্র সত্যিকারের ব্যবহার করে দেখার সময়। এই টিউটোরিয়ালে যেখানেই App.jsx দেখানো হবে (সেকশন ১৪ থেকে শুরু করে), সেখানে শুরু থেকেই useAxios() কল করা থাকবে, যাতে আপনি এই ভুলটা নিজে না করেন।
এখানেই সেকশন ২৮-এর "সবকিছু একসাথে চালানো ও টেস্ট করা" checklist-এর গুরুত্ব — শুধু কোড লিখে "ঠিক আছে মনে হচ্ছে" ভাবলেই চলবে না, প্রতিটা ফিচার বাস্তবে ব্যবহার করে যাচাই করতে হবে।
৫. React Design Patterns যা এই প্রজেক্টে ব্যবহার করব
শুরুতেই নাম করে রাখা ভালো — নিচের প্রতিটা প্যাটার্ন বাস্তব React প্রজেক্টে ব্যাপকভাবে ব্যবহৃত হয়:
Table 5.1: এই প্রজেক্টে ব্যবহৃত React Design Pattern
| প্যাটার্ন | কোথায় ব্যবহার করব | কেন |
|---|---|---|
| Provider Pattern | AuthContext + AuthProvider | গ্লোবাল state (auth) prop drilling ছাড়াই সব কম্পোনেন্টে পৌঁছানো |
| Custom Hook Pattern | useAuth, useAxios, usePosts, useProfile | Logic আলাদা, পুনঃব্যবহারযোগ্য, component থেকে বিচ্ছিন্ন |
| Service Layer Pattern | authService, postService, profileService | সব API কল এক জায়গায়, component-এ raw axios কল না থাকা |
| Reducer Pattern | profileReducer + useReducer | Multi-field, complex state predictable ভাবে আপডেট করা |
| Protected Route Pattern | ProtectedRoute | Auth ছাড়া নির্দিষ্ট route-এ ঢোকা আটকানো, কেন্দ্রীয়ভাবে |
| Container/Presentational | FeedPage (container) বনাম PostFeed/PostCard (presentational) | Data-fetching আর UI রেন্ডারিং আলাদা রাখা |
| Lifting State Up | usePosts-এর state FeedPage-এ থাকে, children-এ callback পাস হয় | নতুন পোস্ট তৈরি হলে পুরো লিস্ট আবার fetch করার দরকার হয় না |
এই টেবিলটা মনে রাখুন — প্রতিটা সেকশনে যখন এই প্যাটার্নগুলোর একটা ব্যবহার হবে, আমরা নাম ধরেই উল্লেখ করব।
৬. Action Type Constants
// src/actions/index.js
export const actions = {
profile: {
PROFILE_FETCHING: "PROFILE_FETCHING",
PROFILE_FETCHED: "PROFILE_FETCHED",
PROFILE_FETCH_ERROR: "PROFILE_FETCH_ERROR",
PROFILE_UPDATED: "PROFILE_UPDATED",
IMAGE_UPDATED: "IMAGE_UPDATED",
},
};Reducer action টাইপগুলো স্ট্রিং literal হিসেবে ছড়িয়ে-ছিটিয়ে না লিখে এক জায়গায় constant হিসেবে রাখা হচ্ছে — টাইপো ধরা পড়বে দ্রুত (IDE autocomplete কাজ করবে), আর reducer-এ কোন action গুলো handle হয় তার একটা centralized তালিকাও পাওয়া যাবে।
৭. AuthContext — Provider Pattern
// src/context/AuthContext.jsx
import { createContext, useState } from "react";
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [auth, setAuth] = useState(null);
// auth object: { user: { id, firstName, lastName, email, avatar }, authToken, refreshToken }
return (
<AuthContext.Provider value={{ auth, setAuth }}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;Provider Pattern — AuthContext.Provider-এর ভেতরে যা কিছু থাকবে, তার সবার কাছে auth আর setAuth সরাসরি পাওয়া যাবে, কোনো prop drilling ছাড়াই। আমরা এটা main.jsx-এ পুরো অ্যাপকে wrap করব (সেকশন ১৪)।
৮. useAuth — Custom Hook দিয়ে Context সহজে ব্যবহার করা
// src/hooks/useAuth.js
import { useContext } from "react";
import AuthContext from "../context/AuthContext";
const useAuth = () => useContext(AuthContext);
export default useAuth;প্রতিটা কম্পোনেন্টে useContext(AuthContext) না লিখে, useAuth() লিখলেই চলবে — এটাই Custom Hook Pattern-এর সবচেয়ে সাধারণ ব্যবহার: boilerplate একবার লিখে বারবার পুনঃব্যবহার করা।
৯. useAxios — Interceptor দিয়ে Auto Token-Refresh
// src/hooks/useAxios.js
import { useEffect } from "react";
import api from "../api/api";
import useAuth from "./useAuth";
const useAxios = () => {
const { auth, setAuth } = useAuth();
useEffect(() => {
// Request Interceptor — প্রতিটা request পাঠানোর ঠিক আগে চলে
const requestIntercept = api.interceptors.request.use(
(config) => {
const token = auth?.authToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// FormData (ছবি আপলোড) হলে Content-Type নিজে সেট করবেন না —
// browser নিজেই সঠিক boundary সহ multipart/form-data বসায়
if (config.data instanceof FormData) {
delete config.headers["Content-Type"];
} else {
config.headers["Content-Type"] = "application/json";
}
return config;
},
(error) => Promise.reject(error),
);
// Response Interceptor — response আসার পরে, catch-এ পৌঁছানোর আগে চলে
const responseIntercept = api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// এখনো একবারও refresh চেষ্টা করা হয়নি — এমন 401 হলে retry করব
const isFirstFailure =
error.response?.status === 401 && !originalRequest.alreadyRetried;
if (isFirstFailure) {
// মার্ক করে দিলাম — এই request একবার retry হয়ে গেছে
// এর ফলে refresh নিজেও fail করলে আবার এই if-এ ঢুকবে না — infinite loop বন্ধ
originalRequest.alreadyRetried = true;
try {
const { data } = await api.post("/auth/refresh-token", {
refreshToken: auth?.refreshToken,
});
setAuth((prev) => ({
...prev,
authToken: data.token,
refreshToken: data.refreshToken,
}));
originalRequest.headers.Authorization = `Bearer ${data.token}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh token-ও কাজ করলো না — session শেষ
setAuth(null);
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
},
);
return () => {
api.interceptors.request.eject(requestIntercept);
api.interceptors.response.eject(responseIntercept);
};
}, [auth]);
return { api };
};
export default useAxios;৯.১ কেন এভাবে?
- Request Interceptor — প্রতিটা API কলে ম্যানুয়ালি
Authorizationহেডার বসানোর দরকার নেই; একবার এখানে লিখে দিলেই সব জায়গায় স্বয়ংক্রিয়ভাবে কাজ করে। - Response Interceptor — access token মেয়াদ শেষ হয়ে ৪০১ আসলে, ব্যবহারকারীকে লগআউট না করে, স্বয়ংক্রিয়ভাবে refresh token দিয়ে নতুন access token নিয়ে একই request আবার পাঠানো হয় (
return api(originalRequest)) — ব্যবহারকারী কিছুই টের পান না। alreadyRetriedফ্ল্যাগ — এটা ছাড়া refresh token নিজেও invalid হলে infinite retry loop-এ আটকে যেতে পারত।
🚨 এই hook শুধু লিখলেই হবে না, কোথাও call করতেও হবে
useAxiosলেখা মানেই interceptor কাজ করা শুরু হয়ে যায় না — এই ফাংশনটা আসলে call করতে হবে, কোনো একবার mount হওয়া কম্পোনেন্টে (যেমনApp.jsx-এর একদম উপরে)। এটা এত সহজে ভুলে যাওয়ার মতো একটা ধাপ যে, খোদ এই প্রজেক্টের প্রথম সংস্করণেই এই hook define করার পর কোথাও call করা হয়নি — ফলাফলে প্রতিটা protected request"Please Provide Valid Token"error দিয়েছে, যদিও লগইন সফল হয়েছিল। সেকশন ১৪-এ App.jsx বানানোর সময় এটা কল করতে ভুলবেন না (বিস্তারিত সেকশন ৪.৫-এ)।
১০. Auth Service
// src/services/authService.js
import api from "../api/api";
export const registerUser = ({ email, password, firstName, lastName }) =>
api.post("/auth/register", { email, password, firstName, lastName });
export const loginUser = (email, password) =>
api.post("/auth/login", { email, password });
export const refreshAccessToken = (refreshToken) =>
api.post("/auth/refresh-token", { refreshToken });Service Layer Pattern — এই ফাইলটাই auth-সংক্রান্ত সব API কলের একমাত্র উৎস। LoginForm/RegisterForm কম্পোনেন্ট সরাসরি axios জানবে না, শুধু এই ফাংশনগুলো কল করবে।
🔧 Git Commit
git add src/actions src/context src/hooks/useAuth.js src/hooks/useAxios.js src/services/authService.js git commit -m "feat: add auth context, useAxios interceptor hook and auth service"
১১. Facebook-Style ডিজাইন সিস্টেম — কালার আর শেপ
কোড লেখার আগে ছোট্ট একটা ডিজাইন সিদ্ধান্ত ঠিক করে নেওয়া যাক, যেটা বাকি পুরো প্রজেক্ট জুড়ে consistent থাকবে:
Table 11.1: Facebook-inspired ডিজাইন টোকেন
| টোকেন | মান | ব্যবহার |
|---|---|---|
| Primary Blue | #1877F2 | Logo, primary বাটন, লিংক, active state |
| Signup Green | #42B72A | "Sign Up"/"Create Account"-এর মতো secondary CTA |
| Page Background | #F0F2F5 | পুরো অ্যাপের বেস ব্যাকগ্রাউন্ড (সাদা কার্ডের বিপরীতে) |
| Card Elevation | shadow | ভারী border না দিয়ে হালকা shadow দিয়ে কার্ড আলাদা করা |
| Corner Radius | rounded-lg/rounded-full | কার্ড/বাটনে মাঝারি radius, অ্যাভাটার/পিল ইনপুটে full radius |
একটা গুরুত্বপূর্ণ স্টাইল সিদ্ধান্ত: আসল Facebook UI ভারী
borderব্যবহার করে না — কার্ডগুলো আলাদা হয় হালকা shadow দিয়ে, ভেতরের সেকশন আলাদা হয় পাতলা এক-লাইনেরborder-t/border-bদিয়ে, চারদিকে বেষ্টনী-করা বক্স দিয়ে না। এই টিউটোরিয়ালের প্রতিটা কম্পোনেন্টে আমরা এই নীতি মেনে চলব।
১২. LoginForm — Facebook Login পেজের ডিজাইন
// src/components/LoginForm.jsx
import { useState } from "react";
import { useNavigate, useLocation, Link } from "react-router";
import useAuth from "../hooks/useAuth";
import { loginUser } from "../services/authService";
export default function LoginForm() {
const { setAuth } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [form, setForm] = useState({
email: location.state?.email ?? "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const response = await loginUser(form.email, form.password);
// Server থেকে আসা response: { user: {...}, token: { token, refreshToken } }
const { user, token } = response.data;
const { token: authToken, refreshToken } = token;
setAuth({ user, authToken, refreshToken });
navigate("/");
} catch (err) {
setError(
err.response?.data?.error || "লগইন ব্যর্থ হয়েছে। আবার চেষ্টা করুন।",
);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-56px)] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-[400px]">
<div className="text-center mb-4">
<h1 className="text-[#1877F2] text-5xl font-bold tracking-tight">
দেশি বুক
</h1>
<p className="text-gray-600 text-xl mt-2">
বন্ধু, পরিবার আর চেনা মানুষদের সাথে যুক্ত হোন।
</p>
</div>
{location.state?.justRegistered && (
<p className="bg-[#E7F3FF] text-[#1877F2] text-sm rounded-md px-3 py-2 mb-3 text-center">
একাউন্ট তৈরি হয়েছে! এখন লগইন করুন।
</p>
)}
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-4 space-y-3"
>
<input
name="email"
type="email"
placeholder="ইমেইল"
value={form.email}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-4 py-3 text-base outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
<input
name="password"
type="password"
placeholder="পাসওয়ার্ড"
value={form.password}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-4 py-3 text-base outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
{error && <p className="text-red-600 text-sm text-center">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-[#1877F2] hover:bg-[#166fe5] text-white font-bold py-2.5 rounded-md text-lg disabled:opacity-60"
>
{loading ? "লগইন হচ্ছে..." : "লগইন করুন"}
</button>
<p className="text-center">
<a href="#" className="text-[#1877F2] text-sm hover:underline">
পাসওয়ার্ড ভুলে গেছেন?
</a>
</p>
<hr className="border-gray-200" />
<div className="flex justify-center">
<Link
to="/register"
className="bg-[#42B72A] hover:bg-[#36a420] text-white font-semibold px-4 py-2.5 rounded-md text-base inline-block"
>
নতুন একাউন্ট খুলুন
</Link>
</div>
</form>
</div>
</div>
);
}লক্ষ্য করুন — useLocation() দিয়ে RegisterForm থেকে পাঠানো state (justRegistered, email) পড়া হচ্ছে, যাতে রেজিস্ট্রেশনের পর এখানে এলে একটা সফলতার বার্তা দেখানো যায়, আর ইমেইল ফিল্ডটা আগে থেকেই পূরণ থাকে।
১৩. RegisterForm — Register-এর পর Login পেজে পাঠানো
আগের সংস্করণে register-এর নিজের response থেকেই সরাসরি session শুরু করা হতো (সেকশন ৪.৪-এর বাগ এড়াতে)। কিন্তু বাস্তব Facebook-এর মতো UX চাইলে — রেজিস্ট্রেশনের পর ইউজারকে লগইন পেজে পাঠানো, নিজের ক্রেডেনশিয়াল দিয়ে সরাসরি লগইন করানো — এটাই বেশি স্বাভাবিক। যেহেতু সেকশন ৪.৪-এ সার্ভারের বাগটা সরাসরি ফিক্স করে দেওয়া হয়েছে, এখন register-এর পর সত্যিকারের login করানো নিরাপদ।
// src/components/RegisterForm.jsx
import { useState } from "react";
import { useNavigate, Link } from "react-router";
import { registerUser } from "../services/authService";
export default function RegisterForm() {
const navigate = useNavigate();
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await registerUser(form);
// রেজিস্ট্রেশনের পর সরাসরি লগইন না করিয়ে, লগইন পেজে পাঠাচ্ছি —
// ইউজার নিজের ইমেইল-পাসওয়ার্ড দিয়ে সরাসরি লগইন করবেন।
navigate("/login", {
state: { justRegistered: true, email: form.email },
});
} catch (err) {
setError(
err.response?.data?.error ||
"রেজিস্ট্রেশন ব্যর্থ হয়েছে। আবার চেষ্টা করুন।",
);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-[calc(100vh-56px)] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-[400px]">
<div className="text-center mb-4">
<h1 className="text-[#1877F2] text-4xl font-bold tracking-tight">
দেশি বুক
</h1>
<p className="text-gray-600 text-lg mt-1">
বন্ধু, পরিবারের সাথে যুক্ত থাকুন।
</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-4 space-y-3"
>
<h2 className="text-xl font-semibold text-gray-800 border-b pb-3 mb-1">
নতুন একাউন্ট খুলুন
</h2>
<div className="grid grid-cols-2 gap-2">
<input
name="firstName"
placeholder="প্রথম নাম"
value={form.firstName}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
<input
name="lastName"
placeholder="শেষ নাম"
value={form.lastName}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
</div>
<input
name="email"
type="email"
placeholder="ইমেইল"
value={form.email}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
<input
name="password"
type="password"
placeholder="নতুন পাসওয়ার্ড"
value={form.password}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2.5 text-sm outline-none focus:border-[#1877F2] focus:ring-1 focus:ring-[#1877F2]"
required
/>
{error && <p className="text-red-600 text-sm text-center">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-[#42B72A] hover:bg-[#36a420] text-white font-semibold py-2.5 rounded-md text-lg disabled:opacity-60"
>
{loading ? "একাউন্ট তৈরি হচ্ছে..." : "সাইন আপ"}
</button>
<p className="text-center text-sm text-gray-600 pt-1">
আগে থেকেই একাউন্ট আছে?{" "}
<Link to="/login" className="text-[#1877F2] font-medium hover:underline">
লগইন করুন
</Link>
</p>
</form>
</div>
</div>
);
}দুটো ফর্মই এখন একে অপরের সাথে লিংক করা — LoginForm-এর নিচে "নতুন একাউন্ট খুলুন" বাটন /register-এ নিয়ে যায়, আর RegisterForm-এর নিচে "লগইন করুন" লিংক /login-এ ফিরিয়ে আনে।
১৪. ProtectedRoute — Protected Route Pattern
// src/components/ProtectedRoute.jsx
import { Navigate } from "react-router";
import useAuth from "../hooks/useAuth";
export default function ProtectedRoute({ children }) {
const { auth } = useAuth();
if (!auth) {
return <Navigate to="/login" replace />;
}
return children;
}Protected Route Pattern — কোন route-এ auth লাগবে সেটা প্রতিটা page component-এর ভেতরে আলাদা করে চেক না করে, একটা centralized wrapper কম্পোনেন্টে রাখা হচ্ছে। ব্যবহার হবে App.jsx-এ (সেকশন ২১)।
main.jsx আপডেট করুন — AuthProvider দিয়ে পুরো অ্যাপ wrap করুন:
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { AuthProvider } from "./context/AuthContext.jsx";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</StrictMode>,
);App.jsx-এ এখনো শুধু login/register route — আর এখনই, শুরু থেকেই, সেকশন ৯-এ বানানো useAxios() হুক কল করে দিচ্ছি:
// src/App.jsx
import { Routes, Route } from "react-router";
import LoginForm from "./components/LoginForm";
import RegisterForm from "./components/RegisterForm";
import useAxios from "./hooks/useAxios";
function App() {
// এই hook-টা কল না করলে interceptor কখনোই attach হয় না — কোনো
// request-এই Authorization header যাবে না, প্রতিটা protected endpoint
// "Please Provide Valid Token" দিয়ে reject করবে (দেখুন সেকশন ৪.৫)।
// App-এর একদম উপরে, সবসময় mounted থাকা জায়গায় একবার কল করলেই যথেষ্ট।
useAxios();
return (
<div className="min-h-screen bg-[#F0F2F5]">
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
</Routes>
</div>
);
}
export default App;
ProtectedRouteএখনোApp.jsx-এ ব্যবহার হচ্ছে না — কারণ যেটা protect করব (FeedPage) এখনো বানানো হয়নি। পরের সেকশনে বানিয়ে তারপর জোড়া লাগাব। কিন্তুuseAxios()কলটা এখনই বসিয়ে দিলাম, যাতে ভবিষ্যতে যোগ হওয়া প্রতিটা feature সঠিকভাবে authenticated request পাঠাতে পারে — এই হুক পরে যোগ করতে ভুলে যাওয়ার সুযোগই রাখছি না।
🔧 Git Commit
git add src/components/LoginForm.jsx src/components/RegisterForm.jsx src/components/ProtectedRoute.jsx src/main.jsx src/App.jsx git commit -m "feat: add login, register forms and protected route"
ফিচার ১: নিউজ ফিড
১৫. Post Service — সংশোধিত Endpoint বোঝাপড়া সহ
// src/services/postService.js
import api from "../api/api";
export const getPosts = () => api.get("/posts");
export const getPost = (postId) => api.get(`/posts/${postId}`);
export const createPost = ({ content, image }) => {
const formData = new FormData();
formData.append("postType", image ? "image" : "text");
formData.append("content", content);
if (image) formData.append("image", image);
return api.post("/posts", formData);
};
export const updatePost = (postId, content) => {
const formData = new FormData();
formData.append("content", content);
return api.patch(`/posts/${postId}`, formData);
};
export const deletePost = (postId) => api.delete(`/posts/${postId}`);
// ⚠️ এই endpoint শুধু { message, likeCount } ফেরত দেয় — সম্পূর্ণ post object না।
// তাই caller-কে নিজে থেকেই optimistically likes array আপডেট করতে হবে।
export const toggleLike = (postId) => api.patch(`/posts/${postId}/like`);
// ⚠️ এই endpoint { message, commentCount, comments } ফেরত দেয় — কমেন্ট অ্যারে,
// পুরো post object না। caller-কে শুধু comments অংশ merge করতে হবে।
export const addComment = (postId, comment) =>
api.patch(`/posts/${postId}/comment`, { comment });
export const deleteComment = (postId, commentId) =>
api.delete(`/posts/${postId}/comment/${commentId}`);সেকশন ৪-এ যাচাই করা প্রতিটা quirk এখানে কমেন্ট আকারে লেখা আছে — পরের কোডে (PostCard.jsx) এগুলো কীভাবে হ্যান্ডল করা হয়েছে সেটা দেখব।
১৬. usePosts — Custom Hook দিয়ে State Lift করা
// src/hooks/usePosts.js
import { useState, useEffect, useCallback, useRef } from "react";
import { getPosts } from "../services/postService";
export default function usePosts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const isMountedRef = useRef(true);
const fetchPosts = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await getPosts();
if (isMountedRef.current) setPosts(response.data);
} catch (err) {
if (isMountedRef.current) {
setError(err.response?.data?.error || "পোস্ট লোড করা যায়নি।");
}
} finally {
if (isMountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
isMountedRef.current = true;
fetchPosts();
return () => {
isMountedRef.current = false;
};
}, [fetchPosts]);
// নতুন পোস্ট তৈরি হলে লিস্টের শুরুতে বসিয়ে দেওয়া — পুরো লিস্ট আবার fetch করার দরকার নেই
const prependPost = (newPost) => {
setPosts((prev) => [newPost, ...prev]);
};
// Like/Comment/Edit-এর পর একটা নির্দিষ্ট পোস্ট আপডেট করা
const replacePost = (updatedPost) => {
setPosts((prev) =>
prev.map((post) => (post.id === updatedPost.id ? updatedPost : post)),
);
};
const removePost = (postId) => {
setPosts((prev) => prev.filter((post) => post.id !== postId));
};
return {
posts,
loading,
error,
refetch: fetchPosts,
prependPost,
replacePost,
removePost,
};
}১৫.১ Lifting State Up — কেন এভাবে?
সাধারণ একটা approach হতো: PostFeed কম্পোনেন্ট নিজেই fetch করবে, আর নতুন পোস্ট তৈরি হলে পুরো কম্পোনেন্ট remount করে আবার fetch করানো হবে (একটা key prop বদলে)। এটা কাজ করে, কিন্তু অপ্রয়োজনীয় — network request নষ্ট হয়, আর remount-এর কারণে UI flash করে।
এখানে বদলে state-টা উপরে (FeedPage-এ) তুলে আনা হয়েছে (usePosts হুক), আর prependPost/replacePost/removePost ফাংশনগুলো child কম্পোনেন্টে (CreatePostForm, PostCard) prop হিসেবে পাস করা হয়েছে। নতুন পোস্ট তৈরি হলে, সার্ভার যেটা ফেরত দিয়েছে সেটাই সরাসরি লিস্টের শুরুতে বসিয়ে দেওয়া হয় — কোনো অতিরিক্ত network call ছাড়াই। এটাই React-এর প্রচলিত Lifting State Up প্যাটার্ন।
১৭. CreatePostForm — Facebook Composer-এর মতো Expand হওয়া ফর্ম
আসল Facebook-এ পোস্ট composer শুরুতে একটা pill-shaped placeholder হিসেবে থাকে ("আপনার মনে কী চলছে?"), ক্লিক করলে পুরো ফর্ম expand হয়। আমরাও সেই প্যাটার্ন অনুসরণ করব — একটা লোকাল expanded state দিয়ে।
// src/components/feed/CreatePostForm.jsx
import { useState } from "react";
import useAuth from "../../hooks/useAuth";
import { createPost } from "../../services/postService";
export default function CreatePostForm({ onCreated }) {
const { auth } = useAuth();
const [expanded, setExpanded] = useState(false);
const [content, setContent] = useState("");
const [image, setImage] = useState(null);
const [preview, setPreview] = useState(null);
const [posting, setPosting] = useState(false);
const [error, setError] = useState("");
const avatarUrl = auth?.user?.avatar
? `${import.meta.env.VITE_API_BASE_URL}/${auth.user.avatar}`
: null;
const handleImageChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImage(file);
setPreview(URL.createObjectURL(file));
};
const resetForm = () => {
setContent("");
setImage(null);
setPreview(null);
setExpanded(false);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!content.trim() && !image) return;
setPosting(true);
setError("");
try {
const response = await createPost({ content, image });
onCreated?.(response.data);
resetForm();
} catch (err) {
setError(err.response?.data?.error || "পোস্ট করা যায়নি।");
} finally {
setPosting(false);
}
};
return (
<div className="bg-white rounded-lg shadow p-3 mb-4">
{!expanded ? (
<button
onClick={() => setExpanded(true)}
className="w-full flex items-center gap-2 text-left"
>
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{avatarUrl ? (
<img src={avatarUrl} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-gray-400">👤</span>
)}
</div>
<span className="flex-1 bg-gray-100 hover:bg-gray-200 rounded-full px-4 py-2.5 text-gray-500 text-[15px] transition-colors">
আপনার মনে কী চলছে, {auth?.user?.firstName}?
</span>
</button>
) : (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{avatarUrl ? (
<img src={avatarUrl} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-gray-400">👤</span>
)}
</div>
<p className="font-semibold text-gray-800 text-sm">
{auth?.user?.firstName} {auth?.user?.lastName}
</p>
</div>
<textarea
autoFocus
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={`আপনার মনে কী চলছে, ${auth?.user?.firstName}?`}
rows={3}
className="w-full resize-none outline-none text-lg placeholder:text-gray-400"
/>
{preview && (
<img src={preview} alt="preview" className="max-h-64 w-full object-cover rounded-md" />
)}
<div className="flex items-center justify-between border-t pt-3">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer hover:bg-gray-100 rounded-md px-2 py-1.5">
🖼️ ছবি/ভিডিও
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
<div className="flex gap-2">
<button
type="button"
onClick={resetForm}
className="text-gray-600 text-sm px-3 py-1.5 rounded-md hover:bg-gray-100"
>
বাতিল
</button>
<button
type="submit"
disabled={posting || (!content.trim() && !image)}
className="bg-[#1877F2] hover:bg-[#166fe5] text-white text-sm font-semibold px-5 py-1.5 rounded-md disabled:opacity-50"
>
{posting ? "পোস্ট হচ্ছে..." : "পোস্ট করুন"}
</button>
</div>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
</form>
)}
</div>
);
}১৮. PostCard — Facebook-Style Post Card, Author/Like/Comment ফিক্সসহ
// src/components/feed/PostCard.jsx
import { useState } from "react";
import useAuth from "../../hooks/useAuth";
import {
updatePost,
deletePost,
toggleLike,
addComment,
deleteComment,
} from "../../services/postService";
function timeAgo(dateString) {
if (!dateString) return "";
const diffMs = Date.now() - new Date(dateString).getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return "এইমাত্র";
if (mins < 60) return `${mins} মিনিট আগে`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} ঘণ্টা আগে`;
const days = Math.floor(hours / 24);
return `${days} দিন আগে`;
}
export default function PostCard({ post, onReplace, onRemove }) {
const { auth } = useAuth();
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(post.content ?? "");
const [commentText, setCommentText] = useState("");
const [busy, setBusy] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// ⚠️ Server আসলে post.author-এ { id, name, avatar } পাঠায় — firstname/lastname না।
// (Postman ডকসের উদাহরণ response আসলে ভুল ছিল — controller-এর আসল কোড পড়ে আর
// সরাসরি সার্ভারে টেস্ট করে এটা যাচাই করা হয়েছে, দেখুন সেকশন ৪.১)
const authorName = post.author?.name || "Deshi Book User";
const authorAvatar = post.author?.avatar
? `${import.meta.env.VITE_API_BASE_URL}/${post.author.avatar}`
: null;
const likedByMe = post.likes?.some(
(like) => (like?.id ?? like) === auth?.user?.id,
);
const imageUrl = post.image
? `${import.meta.env.VITE_API_BASE_URL}/${post.image}`
: null;
const handleLike = async () => {
try {
await toggleLike(post.id);
// ⚠️ /posts/:id/like endpoint শুধু { message, likeCount } ফেরত দেয়,
// পুরো post object না — তাই এখানে optimistically likes array
// নিজে থেকে আপডেট করছি, response.data দিয়ে পুরো post replace করছি না।
const updatedLikes = likedByMe
? (post.likes ?? []).filter(
(like) => (like?.id ?? like) !== auth?.user?.id,
)
: [...(post.likes ?? []), auth?.user?.id];
onReplace({ ...post, likes: updatedLikes });
} catch (err) {
console.error("লাইক করা যায়নি:", err);
}
};
const handleAddComment = async (e) => {
e.preventDefault();
if (!commentText.trim()) return;
setBusy(true);
try {
const response = await addComment(post.id, commentText);
// ⚠️ এই endpoint { message, commentCount, comments } ফেরত দেয় —
// শুধু comments অংশটুকু merge করছি, বাকি post অপরিবর্তিত রাখছি।
onReplace({ ...post, comments: response.data.comments });
setCommentText("");
} catch (err) {
console.error("কমেন্ট করা যায়নি:", err);
} finally {
setBusy(false);
}
};
const handleDeleteComment = async (commentId) => {
try {
const response = await deleteComment(post.id, commentId);
onReplace({ ...post, comments: response.data.comments });
} catch (err) {
console.error("কমেন্ট মুছা যায়নি:", err);
}
};
const handleSaveEdit = async () => {
setBusy(true);
try {
const response = await updatePost(post.id, editContent);
// ⚠️ এই endpoint আসলে পুরো updated post object-ই ফেরত দেয় (verified),
// তাই এখানে সরাসরি response.data দিয়ে replace করা নিরাপদ।
onReplace(response.data);
setIsEditing(false);
} catch (err) {
console.error("পোস্ট আপডেট করা যায়নি:", err);
} finally {
setBusy(false);
}
};
const handleDelete = async () => {
setMenuOpen(false);
if (!window.confirm("পোস্টটি মুছে ফেলতে চান?")) return;
try {
await deletePost(post.id);
onRemove(post.id);
} catch (err) {
console.error("পোস্ট মুছা যায়নি:", err);
}
};
const isOwner = post.author?.id === auth?.user?.id;
return (
<div className="bg-white rounded-lg shadow mb-4">
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{authorAvatar ? (
<img src={authorAvatar} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-gray-400">👤</span>
)}
</div>
<div>
<p className="font-semibold text-gray-900 text-[15px] leading-tight">
{authorName}
</p>
<p className="text-xs text-gray-500">{timeAgo(post.createAt)}</p>
</div>
</div>
{isOwner && (
<div className="relative">
<button
onClick={() => setMenuOpen((prev) => !prev)}
className="text-gray-500 hover:bg-gray-100 rounded-full w-8 h-8 flex items-center justify-center"
>
⋯
</button>
{menuOpen && (
<div className="absolute right-0 top-9 bg-white shadow-lg rounded-md py-1 w-32 z-10">
<button
onClick={() => {
setIsEditing(true);
setMenuOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100"
>
এডিট করুন
</button>
<button
onClick={handleDelete}
className="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-gray-100"
>
মুছে ফেলুন
</button>
</div>
)}
</div>
)}
</div>
<div className="px-4 pb-2">
{isEditing ? (
<div className="space-y-2">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded-md p-2 resize-none text-[15px] outline-none focus:border-[#1877F2]"
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
disabled={busy}
className="bg-[#1877F2] text-white px-4 py-1.5 rounded-md text-sm font-medium disabled:opacity-60"
>
সেভ করুন
</button>
<button
onClick={() => setIsEditing(false)}
className="text-gray-600 px-4 py-1.5 rounded-md text-sm hover:bg-gray-100"
>
বাতিল
</button>
</div>
</div>
) : (
<p className="text-gray-900 text-[15px] whitespace-pre-wrap">
{post.content}
</p>
)}
</div>
{imageUrl && !isEditing && (
<img src={imageUrl} alt="post" className="w-full max-h-[520px] object-cover" />
)}
{((post.likes?.length ?? 0) > 0 || (post.comments?.length ?? 0) > 0) && (
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-500">
<span>
{(post.likes?.length ?? 0) > 0 && `👍 ${post.likes.length}`}
</span>
<span>
{(post.comments?.length ?? 0) > 0 &&
`${post.comments.length} মন্তব্য`}
</span>
</div>
)}
<div className="border-t border-gray-200 mx-4" />
<div className="flex px-2 py-1">
<button
onClick={handleLike}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-sm font-semibold hover:bg-gray-100 ${
likedByMe ? "text-[#1877F2]" : "text-gray-600"
}`}
>
👍 লাইক
</button>
<button
className="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-sm font-semibold text-gray-600 hover:bg-gray-100"
onClick={() =>
document.getElementById(`comment-input-${post.id}`)?.focus()
}
>
💬 মন্তব্য
</button>
</div>
<div className="border-t border-gray-200 mx-4" />
<div className="px-4 py-3 space-y-2">
{post.comments?.map((c) => {
// ⚠️ Comment-এর author-ও { id, name, avatar } শেপ — firstname না।
const commentAuthorName = c.author?.name || "User";
const commentAvatar = c.author?.avatar
? `${import.meta.env.VITE_API_BASE_URL}/${c.author.avatar}`
: null;
const canDeleteComment =
c.author?.id === auth?.user?.id || isOwner;
return (
<div key={c.id} className="flex items-start gap-2">
<div className="w-7 h-7 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{commentAvatar ? (
<img src={commentAvatar} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-xs text-gray-400">👤</span>
)}
</div>
<div className="flex-1 flex items-start justify-between">
<div className="bg-gray-100 rounded-2xl px-3 py-2">
<p className="font-semibold text-xs text-gray-900">
{commentAuthorName}
</p>
<p className="text-sm text-gray-800">{c.comment}</p>
</div>
{canDeleteComment && (
<button
onClick={() => handleDeleteComment(c.id)}
className="text-gray-400 hover:text-red-500 text-xs ml-2 mt-2 shrink-0"
>
মুছুন
</button>
)}
</div>
</div>
);
})}
<form onSubmit={handleAddComment} className="flex items-center gap-2 pt-1">
<div className="w-7 h-7 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center shrink-0">
{auth?.user?.avatar ? (
<img
src={`${import.meta.env.VITE_API_BASE_URL}/${auth.user.avatar}`}
alt="avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs text-gray-400">👤</span>
)}
</div>
<input
id={`comment-input-${post.id}`}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="একটা মন্তব্য লিখুন..."
className="flex-1 bg-gray-100 rounded-full px-4 py-1.5 text-sm outline-none focus:ring-1 focus:ring-[#1877F2]"
/>
</form>
</div>
</div>
);
}Design-এর দিকটা লক্ষ্য করুন — পুরো কার্ডে মাত্র দুটো পাতলা border-t (like/comment বার আলাদা করতে), বাইরের বেষ্টনী border নেই, শুধু shadow। কমেন্টগুলো rounded-2xl ধূসর "বাবল"-এ, ঠিক আসল Facebook-এর মতো।
১৯. PostFeed — Container/Presentational Pattern-এর "Presentational" অংশ
// src/components/feed/PostFeed.jsx
import PostCard from "./PostCard";
export default function PostFeed({ posts, loading, error, onReplace, onRemove }) {
if (loading) {
return (
<p className="text-center text-gray-500 mt-6 bg-white rounded-lg shadow py-6">
লোড হচ্ছে...
</p>
);
}
if (error) {
return (
<p className="text-center text-red-600 mt-6 bg-white rounded-lg shadow py-6">
{error}
</p>
);
}
if (posts.length === 0) {
return (
<p className="text-center text-gray-500 mt-6 bg-white rounded-lg shadow py-6">
এখনো কোনো পোস্ট নেই। প্রথম পোস্টটা আপনিই করুন!
</p>
);
}
return (
<div>
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onReplace={onReplace}
onRemove={onRemove}
/>
))}
</div>
);
}PostFeed নিজে কোনো data fetch করে না, কোনো state রাখে না — শুধু props অনুযায়ী UI রেন্ডার করে। এটাই Presentational Component। Data-fetching আর business logic থাকবে Container Component-এ, যেটা এখন বানাব।
২০. Navbar — Facebook-Style, অ্যাভাটার সহ
// src/components/Navbar.jsx
import { Link, useNavigate } from "react-router";
import useAuth from "../hooks/useAuth";
export default function Navbar() {
const { auth, setAuth } = useAuth();
const navigate = useNavigate();
const avatarUrl = auth?.user?.avatar
? `${import.meta.env.VITE_API_BASE_URL}/${auth.user.avatar}`
: null;
const handleLogout = () => {
setAuth(null);
navigate("/login");
};
return (
<header className="bg-white shadow sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="text-[#1877F2] text-2xl font-bold tracking-tight">
দেশি বুক
</Link>
{auth && (
<nav className="flex items-center gap-3">
<Link
to={`/profile/${auth.user.id}`}
className="flex items-center gap-2 hover:bg-gray-100 rounded-full pl-1 pr-3 py-1 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-gray-200 overflow-hidden flex items-center justify-center">
{avatarUrl ? (
<img src={avatarUrl} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-sm text-gray-400">👤</span>
)}
</div>
<span className="text-sm font-medium text-gray-700">
{auth.user.firstName}
</span>
</Link>
<button
onClick={handleLogout}
className="text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-full px-3 py-2 transition-colors"
>
লগআউট
</button>
</nav>
)}
</div>
</header>
);
}এখানেও লক্ষ্য করুন — border-b-এর বদলে shadow দিয়ে navbar-টা বাকি পেজ থেকে আলাদা হচ্ছে, ঠিক আসল Facebook-এর মতো।
২১. FeedPage — Container Component, সবকিছু জোড়া লাগানো
// src/pages/FeedPage.jsx
import usePosts from "../hooks/usePosts";
import CreatePostForm from "../components/feed/CreatePostForm";
import PostFeed from "../components/feed/PostFeed";
export default function FeedPage() {
const { posts, loading, error, prependPost, replacePost, removePost } =
usePosts();
return (
<div className="max-w-[600px] mx-auto px-3 py-5">
<CreatePostForm onCreated={prependPost} />
<PostFeed
posts={posts}
loading={loading}
error={error}
onReplace={replacePost}
onRemove={removePost}
/>
</div>
);
}এবার App.jsx-এ Navbar, ProtectedRoute, আর / route যোগ করুন — useAxios() কলটা সেকশন ১৪ থেকেই ছিল, সেটা অপরিবর্তিত থাকবে:
// src/App.jsx
import { Routes, Route } from "react-router";
import Navbar from "./components/Navbar";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginForm from "./components/LoginForm";
import RegisterForm from "./components/RegisterForm";
import FeedPage from "./pages/FeedPage";
import useAxios from "./hooks/useAxios";
function App() {
useAxios();
return (
<div className="min-h-screen bg-[#F0F2F5]">
<Navbar />
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route
path="/"
element={
<ProtectedRoute>
<FeedPage />
</ProtectedRoute>
}
/>
</Routes>
</div>
);
}
export default App;🔧 Git Commit
git add src/services/postService.js src/hooks/usePosts.js src/components/feed src/components/Navbar.jsx src/pages/FeedPage.jsx src/App.jsx git commit -m "feat: add news feed - create post, like, comment and post feed"
ফিচার ২: প্রোফাইল
২২. profileReducer — Reducer Pattern
// src/reducers/profileReducer.js
import { actions } from "../actions";
export const initialProfileState = {
loading: true,
error: "",
user: null,
posts: [],
};
export default function profileReducer(state, action) {
switch (action.type) {
case actions.profile.PROFILE_FETCHING:
return { ...state, loading: true, error: "" };
case actions.profile.PROFILE_FETCHED:
return {
...state,
loading: false,
user: action.data.user,
posts: action.data.posts,
};
case actions.profile.PROFILE_FETCH_ERROR:
return { ...state, loading: false, error: action.error };
case actions.profile.PROFILE_UPDATED:
// ⚠️ Server-এর PATCH /profile/:id বাগ: এটা req.body-কে সরাসরি user
// রেকর্ডে merge করে, বানান/কেসিং না মিলিয়েই। ফলে "firstname" (ছোট হাতের)
// পাঠালে আসল "firstName" ফিল্ড অপরিবর্তিত থেকে যায়, শুধু নতুন একটা
// "firstname" ফিল্ড যোগ হয়। এখানে ...state.user, ...action.data দিয়ে
// পুরনো আর নতুন দুটোই state-এ থাকে — component-এ পড়ার সময় সঠিক মান
// বেছে নেওয়ার দায়িত্ব থাকবে (দেখুন ProfileNameEditor)।
return {
...state,
user: { ...state.user, ...action.data },
};
case actions.profile.IMAGE_UPDATED:
return {
...state,
user: { ...state.user, avatar: action.data.avatar },
};
default:
return state;
}
}২২.১ কেন useState না, useReducer?
Profile state-এ একসাথে অনেকগুলো ফিল্ড (loading, error, user, posts) আছে, আর একাধিক ধরনের আপডেট (fetch শুরু, fetch সফল, fetch ব্যর্থ, নাম আপডেট, ছবি আপডেট) হতে পারে। প্রতিটার জন্য আলাদা useState ব্যবহার করলে, একই সময়ে একাধিক setState কল করতে হতো, আর কোন state কখন কীভাবে বদলাবে তার লজিক কম্পোনেন্টের ভেতরে ছড়িয়ে থাকত। useReducer দিয়ে সব state-পরিবর্তনের লজিক এক জায়গায় (profileReducer) predictable ভাবে রাখা যায় — অনেকটা Redux-এর reducer-এর মতোই (আমাদের "দেশি দোকান" প্রজেক্টের cartSlice-এর সাথে ধারণাগতভাবে খুব মিল, শুধু সেখানে Redux Toolkit createSlice ব্যবহার হয়েছে, আর এখানে React-এর বিল্ট-ইন useReducer)।
২৩. Profile Service
// src/services/profileService.js
import api from "../api/api";
export const getProfile = (userId) => api.get(`/profile/${userId}`);
export const updateProfile = (userId, { firstname, lastName }) =>
api.patch(`/profile/${userId}`, { firstname, lastName });
export const uploadAvatar = (userId, file) => {
const formData = new FormData();
formData.append("avatar", file);
return api.post(`/profile/${userId}/avatar`, formData);
};২৪. useProfile Custom Hook
// src/hooks/useProfile.js
import { useReducer, useEffect, useCallback } from "react";
import profileReducer, { initialProfileState } from "../reducers/profileReducer";
import { actions } from "../actions";
import { getProfile } from "../services/profileService";
export default function useProfile(userId) {
const [state, dispatch] = useReducer(profileReducer, initialProfileState);
const fetchProfile = useCallback(async () => {
dispatch({ type: actions.profile.PROFILE_FETCHING });
try {
const response = await getProfile(userId);
dispatch({ type: actions.profile.PROFILE_FETCHED, data: response.data });
} catch (err) {
dispatch({
type: actions.profile.PROFILE_FETCH_ERROR,
error: err.response?.data?.error || "প্রোফাইল লোড করা যায়নি।",
});
}
}, [userId]);
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
return { state, dispatch };
}useReducer-এর dispatch ফাংশনটা হুক থেকে বাইরে (ProfilePage-এ, এবং সেখান থেকে child কম্পোনেন্টে) পাস করা হচ্ছে, যাতে ProfileImage আর ProfileNameEditor নিজে থেকেই profile state আপডেট করতে পারে — তাদের নিজস্ব কোনো state duplicate রাখতে হয় না।
২৫. ProfileImage — Cover Photo-র উপর Overlap করা অ্যাভাটার
// src/components/profile/ProfileImage.jsx
import { useRef, useState } from "react";
import { actions } from "../../actions";
import { uploadAvatar } from "../../services/profileService";
export default function ProfileImage({ user, isOwner, dispatch }) {
const fileInputRef = useRef(null);
const [uploading, setUploading] = useState(false);
const avatarUrl = user.avatar
? `${import.meta.env.VITE_API_BASE_URL}/${user.avatar}`
: null;
const handleFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const response = await uploadAvatar(user.id, file);
dispatch({ type: actions.profile.IMAGE_UPDATED, data: response.data });
} catch (err) {
console.error("ছবি আপলোড করা যায়নি:", err);
} finally {
setUploading(false);
}
};
return (
<div className="relative">
<div
className="w-36 h-36 rounded-full bg-gray-200 border-4 border-white shadow overflow-hidden flex items-center justify-center cursor-pointer"
onClick={() => isOwner && fileInputRef.current?.click()}
>
{avatarUrl ? (
<img src={avatarUrl} alt="avatar" className="w-full h-full object-cover" />
) : (
<span className="text-5xl text-gray-400">👤</span>
)}
</div>
{isOwner && (
<>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="absolute bottom-1 right-1 w-9 h-9 rounded-full bg-gray-200 hover:bg-gray-300 border-2 border-white shadow flex items-center justify-center text-sm"
>
{uploading ? "…" : "📷"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</>
)}
</div>
);
}শুধু ৪ পিক্সেলের সাদা border-4 border-white রাখা হয়েছে — এটা decoration না, বাস্তবেই দরকার: cover photo-র রঙের উপর অ্যাভাটারটাকে আলাদা করে দেখানোর জন্য এই সাদা রিং লাগবে, ঠিক আসল Facebook প্রোফাইলের মতো।
২৬. ProfileNameEditor — firstname/firstName Quirk হ্যান্ডলিং
// src/components/profile/ProfileNameEditor.jsx
import { useState } from "react";
import { actions } from "../../actions";
import { updateProfile } from "../../services/profileService";
export default function ProfileNameEditor({ user, isOwner, dispatch }) {
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
// ⚠️ Server-এর quirk: update-এর পর "firstname" (lowercase, নতুন) আর
// "firstName" (আসল, পুরনো) — দুটো ফিল্ডই state.user-এ থেকে যেতে পারে।
// তাই lowercase-টা আগে চেক করছি — সেটাই সর্বশেষ আপডেট করা মান।
const displayFirstName = user?.firstname ?? user?.firstName ?? "";
const displayLastName = user?.lastName ?? "";
const [form, setForm] = useState({
firstname: displayFirstName,
lastName: displayLastName,
});
const handleSave = async () => {
setSaving(true);
try {
const response = await updateProfile(user.id, form);
dispatch({ type: actions.profile.PROFILE_UPDATED, data: response.data });
setIsEditing(false);
} catch (err) {
console.error("প্রোফাইল আপডেট করা যায়নি:", err);
} finally {
setSaving(false);
}
};
if (isEditing) {
return (
<div className="flex flex-col items-center gap-2 mt-3">
<div className="flex gap-2">
<input
value={form.firstname}
onChange={(e) => setForm({ ...form, firstname: e.target.value })}
className="border border-gray-300 rounded-md p-1.5 text-center text-sm outline-none focus:border-[#1877F2]"
placeholder="প্রথম নাম"
/>
<input
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
className="border border-gray-300 rounded-md p-1.5 text-center text-sm outline-none focus:border-[#1877F2]"
placeholder="শেষ নাম"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="bg-[#1877F2] text-white px-4 py-1.5 rounded-md text-sm font-medium disabled:opacity-60"
>
{saving ? "সেভ হচ্ছে..." : "সেভ করুন"}
</button>
<button
onClick={() => setIsEditing(false)}
className="text-gray-600 px-4 py-1.5 rounded-md text-sm hover:bg-gray-100"
>
বাতিল
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 mt-3">
<h1 className="text-2xl font-bold text-gray-900">
{displayFirstName} {displayLastName}
</h1>
{isOwner && (
<button
onClick={() => setIsEditing(true)}
className="text-gray-600 hover:bg-gray-100 rounded-full w-8 h-8 flex items-center justify-center text-sm"
title="নাম এডিট করুন"
>
✏️
</button>
)}
</div>
);
}২৭. ProfilePage — Cover Photo + Overlapping Avatar Layout
// src/pages/ProfilePage.jsx
import { useParams } from "react-router";
import useAuth from "../hooks/useAuth";
import useProfile from "../hooks/useProfile";
import ProfileImage from "../components/profile/ProfileImage";
import ProfileNameEditor from "../components/profile/ProfileNameEditor";
export default function ProfilePage() {
const { id } = useParams();
const { auth } = useAuth();
const { state, dispatch } = useProfile(id);
if (state.loading) {
return <p className="text-center text-gray-500 mt-10">লোড হচ্ছে...</p>;
}
if (state.error) {
return <p className="text-center text-red-600 mt-10">{state.error}</p>;
}
const isOwner = auth?.user?.id === id;
return (
<div className="max-w-[900px] mx-auto pb-6">
{/* Cover Photo */}
<div className="h-48 sm:h-64 bg-gradient-to-r from-[#1877F2] to-[#42a5f5] rounded-b-lg" />
{/* Avatar overlapping cover photo + name */}
<div className="flex flex-col items-center -mt-16 px-4">
<ProfileImage user={state.user} isOwner={isOwner} dispatch={dispatch} />
<ProfileNameEditor user={state.user} isOwner={isOwner} dispatch={dispatch} />
<p className="text-gray-500 text-sm mt-1">{state.user?.email}</p>
</div>
<div className="border-t border-gray-200 mt-4 mx-4" />
<div className="max-w-[600px] mx-auto px-3 pt-4">
<h2 className="font-semibold text-gray-700 mb-3 px-1">পোস্টসমূহ</h2>
{state.posts.length === 0 && (
<p className="text-gray-500 text-sm bg-white rounded-lg shadow px-4 py-6 text-center">
এখনো কোনো পোস্ট নেই।
</p>
)}
{/* এই লিস্টটা এখনো read-only — লাইক/কমেন্ট বাটন নেই।
সেকশন "আপনার জন্য একটা কাজ"-এ এটাকে ফিচার ১-এর PostCard-এর মতোই
interactive করে তোলার অনুশীলন থাকবে। */}
{state.posts.map((post) => {
const imageUrl = post.image
? `${import.meta.env.VITE_API_BASE_URL}/${post.image}`
: null;
return (
<div key={post.id} className="bg-white rounded-lg shadow mb-3 p-4">
<p className="text-gray-900 text-[15px] whitespace-pre-wrap">
{post.content}
</p>
{imageUrl && (
<img
src={imageUrl}
alt="post"
className="mt-2 rounded-md max-h-72 w-full object-cover"
/>
)}
<div className="flex gap-4 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
<span>👍 {post.likes?.length ?? 0}</span>
<span>💬 {post.comments?.length ?? 0}</span>
</div>
</div>
);
})}
</div>
</div>
);
}App.jsx-এ শেষ route, /profile/:id, যোগ করুন — এখন থেকে এটাই আমাদের সম্পূর্ণ, চূড়ান্ত App.jsx:
// src/App.jsx
import { Routes, Route } from "react-router";
import Navbar from "./components/Navbar";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginForm from "./components/LoginForm";
import RegisterForm from "./components/RegisterForm";
import FeedPage from "./pages/FeedPage";
import ProfilePage from "./pages/ProfilePage";
import useAxios from "./hooks/useAxios";
function App() {
useAxios();
return (
<div className="min-h-screen bg-[#F0F2F5]">
<Navbar />
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/register" element={<RegisterForm />} />
<Route
path="/"
element={
<ProtectedRoute>
<FeedPage />
</ProtectedRoute>
}
/>
<Route
path="/profile/:id"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
</Routes>
</div>
);
}
export default App;🔧 Git Commit
git add src/reducers src/services/profileService.js src/hooks/useProfile.js src/components/profile src/pages/ProfilePage.jsx src/App.jsx git commit -m "feat: add profile page with avatar upload and name editing"
২৮. সবকিছু একসাথে চালানো ও টেস্ট করা
দুটো টার্মিনাল লাগবে — একটা ব্যাকএন্ডের জন্য, একটা ফ্রন্টএন্ডের জন্য।
# টার্মিনাল ১ — ব্যাকএন্ড (সেকশন ৪.৪-এর ফিক্সসহ)
cd facebook-clone-server
npm start# টার্মিনাল ২ — ফ্রন্টএন্ড
cd deshi-book
npm run devhttp://localhost:5173 খুলে এই checklist ধরে টেস্ট করুন:
- রেজিস্ট্রেশন করুন — সফল হলে লগইন পেজে চলে যাওয়ার কথা, একটা "একাউন্ট তৈরি হয়েছে!" বার্তাসহ, ইমেইল ফিল্ড আগে থেকেই পূরণ থাকা অবস্থায়।
- সেই একাউন্ট দিয়ে সরাসরি লগইন করুন — এটা সফল হওয়া উচিত (সেকশন ৪.৪-এর সার্ভার ফিক্স ঠিকমতো লাগানো থাকলে)। ফিড পেজে চলে যাওয়ার কথা।
- ফিড লোড হচ্ছে কিনা দেখুন — কোনো "Please Provide Valid Token" এরর আসা উচিত না (সেকশন ৪.৫-এর ফিক্স কাজ করছে কিনা এখানেই ধরা পড়বে)।
- লগআউট করে আবার লগইন করুন।
- একটা টেক্সট পোস্ট আর একটা ছবিসহ পোস্ট তৈরি করুন — composer-এ ক্লিক করলে expand হচ্ছে কিনা দেখুন।
- পোস্টে লাইক দিন — সংখ্যা বদলাচ্ছে কিনা, আবার ক্লিক করলে আনলাইক হচ্ছে কিনা দেখুন।
- কমেন্ট করুন, তারপর সেই কমেন্ট মুছে ফেলুন।
- নিজের পোস্ট এডিট করুন, তারপর ডিলিট করুন (⋯ মেনু থেকে)।
- প্রোফাইলে গিয়ে ছবি আপলোড করুন, নাম এডিট করুন — cover photo-র উপর অ্যাভাটার overlap করে দেখাচ্ছে কিনা, পরিবর্তন সেভ হচ্ছে কিনা দেখুন।
git log --oneline চালিয়ে দেখুন — ৮টা commit থাকার কথা।
২৯. React Design Patterns — সারসংক্ষেপ
সেকশন ৫-এ যে প্যাটার্নগুলো নাম করেছিলাম, সেগুলো এখন বাস্তবে কোথায় প্রয়োগ হলো তার একটা সারসংক্ষেপ:
- Provider Pattern →
AuthContext+AuthProvider,main.jsx-এ পুরো অ্যাপ wrap করা হয়েছে। - Custom Hook Pattern →
useAuth,useAxios,usePosts,useProfile— প্রতিটাই component থেকে logic আলাদা করেছে। - Service Layer Pattern →
authService,postService,profileService— কোনো component সরাসরি axios জানে না। - Reducer Pattern →
profileReducer— জটিল, multi-field state predictable ভাবে সামলেছে। - Protected Route Pattern →
ProtectedRoute— auth-check কেন্দ্রীভূত রেখেছে। - Container/Presentational →
FeedPage(data + logic) বনামPostFeed/PostCard(শুধু UI)। - Lifting State Up →
usePosts-এর stateFeedPage-এ থাকায়, নতুন পোস্ট তৈরিতে পুরো লিস্ট re-fetch করতে হয়নি।
৩০. আপনার জন্য একটা কাজ
লক্ষ্য করুন, ProfilePage-এর পোস্ট লিস্টে (সেকশন ২৭) কোনো লাইক/কমেন্ট বাটন নেই — এটা এখনো read-only।
নির্দেশনা:
-
profileReducer.js-এ একটা নতুন action type যোগ করুন —POST_IN_PROFILE_UPDATED(এবংPOST_IN_PROFILE_REMOVED, চাইলে) —actions/index.js-এও যোগ করতে ভুলবেন না। -
profileReducer-এ এই নতুন action-এর জন্য একটাcaseলিখুন যাstate.postsঅ্যারের ভেতরে নির্দিষ্ট posts আইটেমটা replace/remove করবে — ঠিক আমাদেরusePosts-এরreplacePost/removePost-এর মতোই লজিক, শুধু এবার reducer-এর ভেতরে। -
ProfilePage.jsx-এ read-only পোস্ট লিস্টের জায়গায় ফিচার ১-এরPostCardকম্পোনেন্টটাই পুনঃব্যবহার করুন —onReplace/onRemoveprop-এ এমন ফাংশন পাস করুন যাdispatch({ type: actions.profile.POST_IN_PROFILE_UPDATED, data: ... })কল করে। -
টেস্ট করুন — নিজের প্রোফাইল পেজ থেকেই কোনো পোস্টে লাইক/কমেন্ট করে দেখুন, ঠিকমতো আপডেট হচ্ছে কিনা।
-
সবশেষে commit করুন:
git add src/actions src/reducers src/pages/ProfilePage.jsx git commit -m "feat: make profile post list interactive" git push
এই exercise আপনাকে useReducer-ভিত্তিক state-এ নতুন action যোগ করা, আর একটা কম্পোনেন্ট (PostCard) দুই ভিন্ন প্রসঙ্গে (Feed আর Profile) পুনঃব্যবহার করার বাস্তব অনুশীলন দেবে।
৩১. সেরা অনুশীলন — Best Practices
React Architecture:
- ✅ Global state (auth) Context-এ, কিন্তু page-নির্দিষ্ট জটিল state (profile)
useReducer-এ — সবকিছু এক জায়গায় গুঁজে দেবেন না। - ✅ Custom hook-এ শুধু logic, JSX না — reusability বজায় থাকে।
- ✅ Service Layer-এর বাইরে কোনো component সরাসরি axios ইমপোর্ট করবে না।
- ✅ Presentational component (
PostFeed,PostCard) props ছাড়া অন্য কোনো ডাটা উৎসের উপর নির্ভর করবে না — সহজে টেস্ট আর পুনঃব্যবহারযোগ্য থাকে।
API Integration:
- ✅ নতুন backend-এর সাথে কাজ শুরুর আগে, অন্তত মূল endpoint গুলো নিজে curl/Postman দিয়ে টেস্ট করে response shape যাচাই করুন — ডকুমেন্টেশন সবসময় বাস্তবতার সাথে মেলে না (সেকশন ৪ দেখুন)।
- ✅ Response থেকে ঠিক কী কী field আসছে সেটা code comment-এ লিখে রাখুন, বিশেষ করে যেখানে expected shape আর actual shape আলাদা।
- ✅ Interceptor-এ token refresh লজিকে একটা "already retried" ফ্ল্যাগ রাখুন — infinite retry loop এড়াতে।
Git Workflow:
- ✅ একটা commit-এ একটাই যুক্তিসঙ্গত পরিবর্তন রাখুন, Conventional Commits প্রিফিক্স ব্যবহার করুন।
- ✅
.envকখনো commit করবেন না —.env.exampleদিয়ে টেমপ্লেট রাখুন। - ✅ Commit করার আগে
git status/git diffদিয়ে দেখে নিন। - ✅ নিয়মিত
git pushকরুন।
৩২. প্রোডাকশনে যাওয়ার আগে যা মাথায় রাখবেন
Table 31.1: এই টিউটোরিয়ালে vs প্রোডাকশনে
| বিষয় | এই টিউটোরিয়ালে | প্রোডাকশনে যা করবেন |
|---|---|---|
| Auth persistence | auth state শুধু মেমোরিতে, reload-এ হারায় | localStorage/sessionStorage-এ token রাখুন, app লোডে rehydrate করুন |
| Password/token লিক | দেখিনি এমন কিছু, কিন্তু always verify করুন | Refresh token httpOnly cookie-তে রাখা বেশি নিরাপদ |
| Backend | json-server ভিত্তিক Express API | Real database (PostgreSQL ইত্যাদি), production-grade auth |
| Image storage | সার্ভারের লোকাল ফাইল সিস্টেম | S3/Cloudflare R2-এর মতো অবজেক্ট স্টোরেজ |
| Environment variable | .env-এ VITE_API_BASE_URL | Vercel/Render dashboard-এ env variable সেট করুন, কখনো commit না |
৩৩. GitHub-এ পুশ করা — Git History রিভিউ
git remote add origin https://github.com/<আপনার-ইউজারনেম>/deshi-book.git
git push -u origin mainএরপর থেকে শুধু git push। git log --oneline চালালে দেখা উচিত:
docs: add project readme
feat: add profile page with avatar upload and name editing
feat: add news feed - create post, like, comment and post feed
feat: add login, register forms and protected route
feat: add auth context, useAxios interceptor hook and auth service
chore: setup axios, react-router and folder structure
style: add tailwind css
chore: scaffold project with vite and react৮টা commit — প্রতিটাই একটা নির্দিষ্ট, স্বনির্ভর পরিবর্তনের প্রতিনিধিত্ব করছে (দেশি দোকান প্রজেক্টের ১২টা commit-এর চেয়ে কম, কারণ এই প্রজেক্টে ফিচারের সংখ্যা কম — commit granularity একই নীতি অনুসরণ করছে)।
উপসংহার
এই গাইডে আপনি দেশি বুক বানিয়ে শিখলেন:
- React Context API ও Provider Pattern দিয়ে গ্লোবাল auth state।
- Axios Interceptor দিয়ে token attach আর automatic refresh-on-401।
useReducerদিয়ে জটিল, multi-field state (Profile) সামলানো।- Custom Hooks (
useAuth,useAxios,usePosts,useProfile) দিয়ে logic আলাদা করা। - Service Layer Pattern দিয়ে সব API কল সংগঠিত রাখা।
- Container/Presentational আর Lifting State Up প্যাটার্ন দিয়ে দক্ষ, পুনঃব্যবহারযোগ্য কম্পোনেন্ট গঠন।
- আসল সার্ভার টেস্ট করার গুরুত্ব — ডকুমেন্টেশন না, বাস্তব response-ই সত্য; এভাবেই চারটা গুরুত্বপূর্ণ quirk/বাগ আবিষ্কার ও সমাধান করা হয়েছে।
- Git Workflow — ফিচার-বাই-ফিচার commit, Conventional Commits, GitHub push।
এই pattern গুলো — Context/Reducer/Custom Hook/Service Layer + ফিচার-বাই-ফিচার Git commit + backend সরাসরি টেস্ট করে verify করার অভ্যাস — যেকোনো production React প্রজেক্টে সরাসরি কাজে লাগবে।
❤️ পাশে থাকুন, সাপোর্ট করুন
এই কমিউনিটি ফ্রি রাখতে আপনার সাপোর্ট গুরুত্বপূর্ণ। ইচ্ছেমতো যেকোনো অ্যামাউন্ট পাঠাতে পারেন নিচের যেকোনো নাম্বারে।
React Portfolio-Ready রিয়েল প্রোজেক্ট
বাংলাদেশি ডেভেলপারদের জন্য React-এর সম্পূর্ণ, real-world প্রজেক্ট — Context API, Redux, RTK Query ও React Query দিয়ে। ধাপে ধাপে কোডসহ, চাকরি ও ফ্রিল্যান্সিং পোর্টফোলিওর জন্য প্রস্তুত।
দেশি দোকান — Redux Toolkit + RTK Query দিয়ে সম্পূর্ণ ই-কমার্স প্রজেক্ট
হাতে-কলমে বানাবো "দেশি দোকান" — একটি সম্পূর্ণ ফাংশনাল ই-কমার্স স্টোর। থাকছে Redux Toolkit দিয়ে Cart State Management, RTK Query দিয়ে Data Fetching ও Mutation (Axios ছাড়াই), json-server দিয়ে Mock Backend, আধুনিক Tailwind CSS UI, React Router দিয়ে Multi-page navigation, আর প্রতিটা ফিচারের পর বাস্তব ডেভেলপারদের মতো Git commit/push workflow। যিনি জীবনে কখনো Redux বা Git ব্যবহার করেননি, তিনিও এই গাইড অনুসরণ করে সম্পূর্ণ প্রজেক্টটা বানাতে পারবেন।