From 55c9500b59ca35629469b3267f9bc65916ae6083 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Mar 2026 01:25:26 +0000
Subject: [PATCH 1/5] Initial plan
From 495e22e5f47953bfabc684a259a2c6996ed470ec Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 25 Mar 2026 01:51:03 +0000
Subject: [PATCH 2/5] feat: implement search, tags, follow, subscriptions,
notifications, lazy loading, lightbox, and tests
- Content Search: POST /api/posts/search and /api/sections/search endpoints; search bar in Home.js
- Content Tags: tags field on Post model; tag input in Section.js; tag pills in Section.js and Post.js
- User Follow: following/followers on User model; follow/unfollow/profile routes in auth.js; UserProfile.js page
- Section Subscription: subscribedSections on User model; subscribe/unsubscribe routes; 'My Subscriptions' tab in Home.js
- Frontend Performance: React.lazy + Suspense for all page components in App.js
- Image Lightbox: full-screen overlay on image click in Post.js
- Notifications: Notification model + routes; Notifications dropdown in Navbar; notifications on like/comment/follow
- Unit Tests: server/tests/models.test.js with 17 tests using jest
- E2E Test: updated App.test.js
- Mark all implemented items as done in todo.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
client/package-lock.json | 24 -
client/src/App.js | 50 +-
client/src/App.test.js | 10 +-
client/src/components/Admin.js | 1 +
client/src/components/Home.css | 62 +-
client/src/components/Home.js | 107 +-
client/src/components/Login.js | 3 +-
client/src/components/Navbar.css | 36 +-
client/src/components/Navbar.js | 120 +-
client/src/components/Notifications.css | 140 +
client/src/components/Notifications.js | 88 +
client/src/components/Post.css | 73 +-
client/src/components/Post.js | 69 +-
client/src/components/Section.css | 42 +-
client/src/components/Section.js | 135 +-
client/src/components/UserProfile.css | 122 +
client/src/components/UserProfile.js | 106 +
package-lock.json | 5419 +++++++++++++++++++++--
package.json | 8 +-
server/app.js | 20 +-
server/models/Notification.js | 13 +
server/models/Post.js | 17 +-
server/models/User.js | 12 +-
server/routes/auth.js | 130 +-
server/routes/comments.js | 60 +-
server/routes/notifications.js | 34 +
server/routes/posts.js | 117 +-
server/routes/sections.js | 108 +-
server/tests/models.test.js | 95 +
todo.md | 20 +-
30 files changed, 6226 insertions(+), 1015 deletions(-)
create mode 100644 client/src/components/Notifications.css
create mode 100644 client/src/components/Notifications.js
create mode 100644 client/src/components/UserProfile.css
create mode 100644 client/src/components/UserProfile.js
create mode 100644 server/models/Notification.js
create mode 100644 server/routes/notifications.js
create mode 100644 server/tests/models.test.js
diff --git a/client/package-lock.json b/client/package-lock.json
index 3dd7584..d23ac36 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -69,7 +69,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -719,7 +718,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz",
"integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
@@ -1603,7 +1601,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
@@ -3346,7 +3343,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3824,7 +3820,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
@@ -3878,7 +3873,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -4248,7 +4242,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4347,7 +4340,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5287,7 +5279,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7122,7 +7113,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -9873,7 +9863,6 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "^27.5.1",
"import-local": "^3.0.2",
@@ -10771,7 +10760,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -12227,7 +12215,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13362,7 +13349,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -13744,7 +13730,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13876,7 +13861,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13901,7 +13885,6 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14399,7 +14382,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"license": "MIT",
- "peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -14642,7 +14624,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -16026,7 +16007,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -16205,7 +16185,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -16642,7 +16621,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -16714,7 +16692,6 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz",
"integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/bonjour": "^3.5.9",
"@types/connect-history-api-fallback": "^1.3.5",
@@ -17128,7 +17105,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
diff --git a/client/src/App.js b/client/src/App.js
index 6cbd04d..d8ff992 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -1,62 +1,56 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import './App.css';
import Navbar from './components/Navbar';
import Login from './components/Login';
-import Home from './components/Home';
-import Section from './components/Section';
-import Post from './components/Post';
-import Admin from './components/Admin';
-import Profile from './components/Profile';
+
+const Home = lazy(() => import('./components/Home'));
+const Section = lazy(() => import('./components/Section'));
+const Post = lazy(() => import('./components/Post'));
+const Admin = lazy(() => import('./components/Admin'));
+const Profile = lazy(() => import('./components/Profile'));
+const UserProfile = lazy(() => import('./components/UserProfile'));
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
-
useEffect(() => {
const token = localStorage.getItem('token');
const userData = localStorage.getItem('user');
- if (token && userData) {
- setUser(JSON.parse(userData));
- }
+ if (token && userData) { setUser(JSON.parse(userData)); }
setLoading(false);
}, []);
-
const handleLogin = (token, userData) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(userData));
setUser(userData);
};
-
const handleUserUpdate = (updatedUser) => {
localStorage.setItem('user', JSON.stringify(updatedUser));
setUser(updatedUser);
};
-
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
};
-
- if (loading) {
- return
加载中...
;
- }
-
+ if (loading) { return 加载中...
; }
return (
-
- : } />
- : } />
- : } />
- : } />
- : } />
- {user?.role === 'admin' && } />}
- } />
-
+ 加载中...}>
+
+ : } />
+ : } />
+ : } />
+ : } />
+ : } />
+ : } />
+ {user?.role === 'admin' && } />}
+ } />
+
+
);
}
-
export default App;
diff --git a/client/src/App.test.js b/client/src/App.test.js
index 1f03afe..06bedf0 100644
--- a/client/src/App.test.js
+++ b/client/src/App.test.js
@@ -1,8 +1,8 @@
-import { render, screen } from '@testing-library/react';
+import { render } from '@testing-library/react';
import App from './App';
-test('renders learn react link', () => {
- render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+test('App renders without crashing', () => {
+ Storage.prototype.getItem = jest.fn(() => null);
+ const { container } = render();
+ expect(container).toBeTruthy();
});
diff --git a/client/src/components/Admin.js b/client/src/components/Admin.js
index c70c75f..fea58ac 100644
--- a/client/src/components/Admin.js
+++ b/client/src/components/Admin.js
@@ -11,6 +11,7 @@ export default function Admin({ user }) {
useEffect(() => {
fetchData();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const fetchData = async () => {
diff --git a/client/src/components/Home.css b/client/src/components/Home.css
index d954e85..cd82941 100644
--- a/client/src/components/Home.css
+++ b/client/src/components/Home.css
@@ -166,4 +166,64 @@
.modal-buttons button:hover {
transform: translateY(-2px);
-}
\ No newline at end of file
+}
+.home-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.tabs {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.tab-btn {
+ padding: 0.5rem 1.25rem;
+ border: 2px solid #667eea;
+ border-radius: 20px;
+ background: white;
+ color: #667eea;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+.tab-btn.active,
+.tab-btn:hover {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border-color: transparent;
+}
+
+.search-bar {
+ flex: 1;
+ max-width: 400px;
+}
+
+.search-input {
+ width: 100%;
+ padding: 0.6rem 1rem;
+ border: 1px solid #ddd;
+ border-radius: 20px;
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s, box-shadow 0.2s;
+ box-sizing: border-box;
+}
+
+.search-input:focus {
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.error-message {
+ background: #fee;
+ color: #c00;
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
diff --git a/client/src/components/Home.js b/client/src/components/Home.js
index 1108676..82dab9c 100644
--- a/client/src/components/Home.js
+++ b/client/src/components/Home.js
@@ -5,64 +5,85 @@ import './Home.css';
export default function Home({ user }) {
const [sections, setSections] = useState([]);
+ const [subscribedSections, setSubscribedSections] = useState([]);
+ const [activeTab, setActiveTab] = useState('all');
const [showModal, setShowModal] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
- useEffect(() => {
- fetchSections();
- }, []);
+ useEffect(() => { fetchSections(); fetchSubscribed(); }, []);
const fetchSections = async () => {
try {
const response = await axios.get('/api/sections');
setSections(response.data);
- } catch (err) {
- setError('加载分区失败');
- } finally {
- setLoading(false);
- }
+ } catch (err) { setError('加载分区失败'); }
+ finally { setLoading(false); }
+ };
+
+ const fetchSubscribed = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ const response = await axios.get('/api/sections/subscribed', { headers: { Authorization: `Bearer ${token}` } });
+ setSubscribedSections(response.data);
+ } catch (err) { /* not critical */ }
};
const handleCreateSection = async (e) => {
e.preventDefault();
setError('');
-
try {
const token = localStorage.getItem('token');
- await axios.post('/api/sections', { name, description }, {
- headers: { Authorization: `Bearer ${token}` }
- });
-
- setName('');
- setDescription('');
- setShowModal(false);
-
- if (user.role === 'admin') {
- fetchSections();
- } else {
- alert('分区创建成功!需要管理员审核后才能显示。');
- }
- } catch (err) {
- setError(err.response?.data?.message || '创建失败');
- }
+ await axios.post('/api/sections', { name, description }, { headers: { Authorization: `Bearer ${token}` } });
+ setName(''); setDescription(''); setShowModal(false);
+ if (user.role === 'admin') { fetchSections(); }
+ else { alert('分区创建成功!需要管理员审核后才能显示。'); }
+ } catch (err) { setError(err.response?.data?.message || '创建失败'); }
};
- if (loading) return 加载中...
;
+ const filteredSections = () => {
+ const source = activeTab === 'subscribed' ? subscribedSections : sections;
+ if (!searchQuery.trim()) return source;
+ const q = searchQuery.toLowerCase();
+ return source.filter(s =>
+ s.name?.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q)
+ );
+ };
+ if (loading) return 加载中...
;
return (
分区列表
-
+
- {error &&
{error}
}
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="search-input"
+ />
+
+
+ {error &&
{error}
}
{showModal && (
@@ -70,22 +91,11 @@ export default function Home({ user }) {
)}
-
- {sections.length === 0 ? (
-
暂无分区,快去创建一个吧!
+ {filteredSections().length === 0 ? (
+
+ {activeTab === 'subscribed' ? '暂无订阅,去分区页面订阅吧!' : '暂无分区,快去创建一个吧!'}
+
) : (
- sections.map((section) => (
+ filteredSections().map((section) => (
{section.name}
{section.description || '暂无描述'}
@@ -111,4 +122,4 @@ export default function Home({ user }) {
);
-}
\ No newline at end of file
+}
diff --git a/client/src/components/Login.js b/client/src/components/Login.js
index 0f88745..52b35a4 100644
--- a/client/src/components/Login.js
+++ b/client/src/components/Login.js
@@ -13,7 +13,6 @@ export default function Login({ onLogin }) {
const [isLogin, setIsLogin] = useState(true);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
- const [userId, setUserId] = useState(null);
const navigate = useNavigate();
const handleAvatarChange = (e) => {
@@ -66,7 +65,7 @@ export default function Login({ onLogin }) {
// 注册后上传头像
const token = response.data?.token;
if (avatar && token) {
- const avatarUrl = await uploadAvatar(token);
+ await uploadAvatar(token);
}
setError('');
diff --git a/client/src/components/Navbar.css b/client/src/components/Navbar.css
index dea98d2..01914e4 100644
--- a/client/src/components/Navbar.css
+++ b/client/src/components/Navbar.css
@@ -320,4 +320,38 @@
.cancel-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
-}
\ No newline at end of file
+}
+.notif-wrapper {
+ position: relative;
+}
+
+.bell-btn {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 20px;
+ cursor: pointer;
+ position: relative;
+ padding: 0.25rem 0.5rem;
+ line-height: 1;
+ transition: transform 0.2s;
+}
+
+.bell-btn:hover {
+ transform: scale(1.15);
+}
+
+.notif-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ background: #ff4757;
+ color: white;
+ border-radius: 10px;
+ padding: 1px 5px;
+ font-size: 10px;
+ font-weight: 700;
+ min-width: 16px;
+ text-align: center;
+ line-height: 1.4;
+}
diff --git a/client/src/components/Navbar.js b/client/src/components/Navbar.js
index f004cef..e5965ff 100644
--- a/client/src/components/Navbar.js
+++ b/client/src/components/Navbar.js
@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
+import Notifications from './Notifications';
import './Navbar.css';
export default function Navbar({ user, onLogout, onUserUpdate }) {
@@ -10,6 +11,35 @@ export default function Navbar({ user, onLogout, onUserUpdate }) {
const [avatarPreview, setAvatarPreview] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
+ const [showNotifications, setShowNotifications] = useState(false);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const notifRef = useRef(null);
+
+ useEffect(() => {
+ if (user) {
+ fetchUnreadCount();
+ const interval = setInterval(fetchUnreadCount, 30000);
+ return () => clearInterval(interval);
+ }
+ }, [user]);
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (notifRef.current && !notifRef.current.contains(e.target)) {
+ setShowNotifications(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const fetchUnreadCount = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ const res = await axios.get('/api/notifications', { headers: { Authorization: `Bearer ${token}` } });
+ setUnreadCount(res.data.filter(n => !n.read).length);
+ } catch (_) {}
+ };
const handleLogout = () => {
onLogout();
@@ -26,32 +56,23 @@ export default function Navbar({ user, onLogout, onUserUpdate }) {
if (file) {
setAvatarFile(file);
const reader = new FileReader();
- reader.onloadend = () => {
- setAvatarPreview(reader.result);
- };
+ reader.onloadend = () => { setAvatarPreview(reader.result); };
reader.readAsDataURL(file);
}
};
const handleUploadAvatar = async () => {
if (!avatarFile) return;
-
setUploading(true);
setError('');
const formData = new FormData();
formData.append('avatar', avatarFile);
-
try {
const response = await axios.post('/api/auth/upload-avatar', formData, {
- headers: {
- Authorization: `Bearer ${localStorage.getItem('token')}`,
- 'Content-Type': 'multipart/form-data'
- }
+ headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'multipart/form-data' }
});
-
const updatedUser = { ...user, avatar: response.data.avatar };
onUserUpdate(updatedUser);
-
setShowAvatarModal(false);
setAvatarFile(null);
setAvatarPreview(null);
@@ -70,16 +91,34 @@ export default function Navbar({ user, onLogout, onUserUpdate }) {
setError('');
};
+ const handleBellClick = () => {
+ setShowNotifications(prev => !prev);
+ if (!showNotifications) {
+ fetchUnreadCount();
+ }
+ };
+
return (
<>
{error && {error}
}
@@ -99,15 +120,8 @@ export default function Section({ user }) {