diff --git a/.gitignore b/.gitignore index e5cc5d6..cb074d0 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/chroma_err.txt b/chroma_err.txt deleted file mode 100644 index 93dff95..0000000 --- a/chroma_err.txt +++ /dev/null @@ -1 +0,0 @@ -C:\Users\beldh\AppData\Local\Programs\Python\Python310\python.exe: No module named chromadb.__main__; 'chromadb' is a package and cannot be directly executed diff --git a/chroma_out.txt b/chroma_out.txt deleted file mode 100644 index e69de29..0000000 diff --git a/client/package-lock.json b/client/package-lock.json index c4c49f1..15985db 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,7 +12,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.4.1", - "react-router-dom": "^7.0.0" + "react-router-dom": "^7.0.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", @@ -1189,6 +1190,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1458,6 +1465,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", + "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.20.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2072,6 +2101,34 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2205,6 +2262,35 @@ } } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index f3d242f..2ee4f31 100644 --- a/client/package.json +++ b/client/package.json @@ -13,7 +13,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.4.1", - "react-router-dom": "^7.0.0" + "react-router-dom": "^7.0.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/client/src/App.jsx b/client/src/App.jsx index a7a544e..52685dd 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,19 +1,31 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import { AuthProvider } from './context/AuthContext'; +import { SocketProvider } from './context/SocketContext'; +import { NotificationProvider } from './context/NotificationContext'; import ProtectedRoute from './components/ProtectedRoute'; import AuthLayout from './components/AuthLayout'; +import DashboardLayout from './components/layout/DashboardLayout'; import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import UserPage from './pages/UserPage'; import QueryPage from './pages/QueryPage'; +import DashboardHome from './pages/DashboardHome'; +import AskQuestion from './pages/AskQuestion'; +import MyQuestions from './pages/MyQuestions'; +import QuestionDetail from './pages/QuestionDetail'; +import AnswerCenter from './pages/AnswerCenter'; +import AdminArea from './pages/AdminArea'; import './styles/auth.css'; +import Leaderboard from './pages/Leaderboard' export default function App() { return ( - + + + {/* Public routes */} } /> } /> - - - - } - /> - - - - } - /> + + {/* Standalone pages */} + } /> + } /> + + {/* Dashboard layout routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Redirects */} } /> - } /> + } /> + + ); -} +} \ No newline at end of file diff --git a/client/src/api/searchApi.js b/client/src/api/searchApi.js index b3420a3..fff9994 100644 --- a/client/src/api/searchApi.js +++ b/client/src/api/searchApi.js @@ -1,12 +1,21 @@ import api from './axios'; +const suggestionsCache = new Map(); +const searchCache = new Map(); + export async function searchFAQs(query) { + const key = query.toLowerCase().trim(); + if (searchCache.has(key)) return searchCache.get(key); const { data } = await api.post('/search', { query }); + searchCache.set(key, data); return data; } export async function getSuggestions(query) { + const key = query.toLowerCase().trim(); + if (suggestionsCache.has(key)) return suggestionsCache.get(key); const { data } = await api.get(`/search/suggestions?q=${encodeURIComponent(query)}`); + suggestionsCache.set(key, data); return data; } @@ -18,4 +27,4 @@ export async function askFAQ(query, history = []) { export async function sendFeedback(faqId, helpful) { const { data } = await api.post('/faqs/feedback', { faqId, helpful }); return data; -} +} \ No newline at end of file diff --git a/client/src/components/ConfirmModal.jsx b/client/src/components/ConfirmModal.jsx new file mode 100644 index 0000000..c2d1f10 --- /dev/null +++ b/client/src/components/ConfirmModal.jsx @@ -0,0 +1,34 @@ +export default function ConfirmModal({ open, title, message, confirmLabel, cancelLabel, variant, onConfirm, onCancel }) { + if (!open) return null; + return ( +
+
e.stopPropagation()} style={{ + background: 'var(--bg-card)', borderRadius: 'var(--radius-md)', + padding: 28, width: 420, maxWidth: '90vw', + boxShadow: '0 25px 60px rgba(0,0,0,0.3)', + display: 'flex', flexDirection: 'column', gap: 16, + }}> + {title &&

{title}

} +
{message}
+
+ + +
+
+
+ ); +} diff --git a/client/src/components/FaqAssistant.jsx b/client/src/components/FaqAssistant.jsx index 6fc4afb..b0a1dc2 100644 --- a/client/src/components/FaqAssistant.jsx +++ b/client/src/components/FaqAssistant.jsx @@ -1,8 +1,10 @@ import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; import { searchFAQs } from '../api/searchApi'; export default function FaqAssistant() { + const { user } = useAuth(); const navigate = useNavigate(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); @@ -103,7 +105,7 @@ export default function FaqAssistant() { ))} )} - {msg.noAnswer && ( + {msg.noAnswer && user.role !== 'admin' && user.role !== 'super_admin' && ( + + {/* Dropdown */} + {open && ( +
+ {/* Header */} +
+ + Notifications + {unreadCount > 0 && ( + {unreadCount} + )} + + {unreadCount > 0 && ( + + )} +
+ + {/* Empty state */} + {!loading && notifications.length === 0 && ( +
+ No notifications yet +
+ )} + + {/* Loading */} + {loading && ( +
+ Loading... +
+ )} + + {/* Notification items */} + {notifications.map(n => ( +
{ markAsRead(n._id); setOpen(false); }} + style={{ + padding: '12px 16px', cursor: 'pointer', + borderBottom: '1px solid var(--border)', + background: n.read ? 'transparent' : 'var(--accent-light)', + borderLeft: n.read ? '3px solid transparent' : '3px solid var(--accent)', + transition: 'background 0.15s', + }} + onMouseOver={e => e.currentTarget.style.background = 'var(--bg-secondary)'} + onMouseOut={e => e.currentTarget.style.background = n.read ? 'transparent' : 'var(--accent-light)'} + > +
+ {n.title} +
+
+ {n.message} +
+
{timeAgo(n.createdAt)}
+
+ ))} +
+ )} + + ); +} \ No newline at end of file diff --git a/client/src/components/layout/DashboardLayout.jsx b/client/src/components/layout/DashboardLayout.jsx new file mode 100644 index 0000000..3adeef5 --- /dev/null +++ b/client/src/components/layout/DashboardLayout.jsx @@ -0,0 +1,19 @@ +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import Header from './Header'; + +const DashboardLayout = () => { + return ( +
+ +
+
+
+ +
+
+
+ ); +}; + +export default DashboardLayout; diff --git a/client/src/components/layout/Header.jsx b/client/src/components/layout/Header.jsx new file mode 100644 index 0000000..2438ba5 --- /dev/null +++ b/client/src/components/layout/Header.jsx @@ -0,0 +1,20 @@ +import { useAuth } from '../../context/AuthContext'; +import NotificationBell from '../NotificationBell'; + +const Header = () => { + const { user } = useAuth(); + + return ( +
+
+ Dashboard / Overview +
+
+ {user?.role === 'admin' && ADMIN MODE} + +
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/client/src/components/layout/Sidebar.jsx b/client/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..afc6b09 --- /dev/null +++ b/client/src/components/layout/Sidebar.jsx @@ -0,0 +1,84 @@ +import { Link, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { useEffect, useState } from 'react'; + +const Sidebar = () => { + const { user, logout } = useAuth(); + const location = useLocation(); + const [dark, setDark] = useState(() => localStorage.getItem('theme') === 'dark'); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); + localStorage.setItem('theme', dark ? 'dark' : 'light'); + }, [dark]); + + const isActive = (path) => location.pathname === path ? 'active' : ''; + const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'; + + const roleLabel = { + super_admin: 'Super Admin', + admin: 'Administrator', + intern: 'Member', + }[user?.role] || 'Member'; + + const roleBadgeStyle = { + super_admin: { background: 'rgba(234,179,8,0.15)', color: '#ca8a04' }, + admin: { background: 'rgba(99,102,241,0.1)', color: 'var(--accent)' }, + intern: { background: 'var(--accent-light)', color: 'var(--accent)' }, + }[user?.role] || {}; + + return ( +
+
+

Crowd Sourced FAQ Generation Web App

+
+
+ FAQ Hub + Dashboard + {!isAdmin && ( + <> + Ask Question + My Questions + + )} + Discussion Room + Leaderboard + {isAdmin && ( + Admin Area + )} +
+
+
+
+ {user?.name || user?.email} +
+
+ {roleLabel} +
+
+ ✨ {user?.points || 0} pts +
+
+ + +
+
+ ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/client/src/context/AuthContext.jsx b/client/src/context/AuthContext.jsx index 009cd29..1e71864 100644 --- a/client/src/context/AuthContext.jsx +++ b/client/src/context/AuthContext.jsx @@ -1,9 +1,7 @@ import { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; import api from '../api/axios'; const AuthContext = createContext(null); - const STORAGE_KEY = 'rememberMe'; function getStorage() { @@ -25,10 +23,7 @@ export function AuthProvider({ children }) { const loadUser = useCallback(async () => { const token = getStorage().getItem('accessToken'); - if (!token) { - setLoading(false); - return; - } + if (!token) { setLoading(false); return; } try { const { data } = await api.get('/auth/me'); setUser(data.user); @@ -39,9 +34,21 @@ export function AuthProvider({ children }) { } }, [clearAuth]); + useEffect(() => { loadUser(); }, [loadUser]); + + // Refresh user points/data without full reload + const refreshUser = useCallback(async () => { + try { + const { data } = await api.get('/auth/me'); + setUser(data.user); + } catch {} + }, []); + + // Auto-refresh points every 30 seconds useEffect(() => { - loadUser(); - }, [loadUser]); + const interval = setInterval(refreshUser, 5*60 * 1000); + return () => clearInterval(interval); + }, [refreshUser]); const handleTokens = (data, rememberMe = false) => { localStorage.setItem(STORAGE_KEY, rememberMe); @@ -59,9 +66,7 @@ export function AuthProvider({ children }) { const register = async (name, email, password) => { const { data } = await api.post('/auth/register', { - name, - email, - password, + name, email, password, confirmPassword: password, termsAccepted: true, }); @@ -78,11 +83,7 @@ export function AuthProvider({ children }) { }; const logout = async () => { - try { - await api.post('/auth/logout'); - } catch { - // proceed with local logout even if API call fails - } + try { await api.post('/auth/logout'); } catch {} clearAuth(); }; @@ -93,22 +94,16 @@ export function AuthProvider({ children }) { const resetPassword = async (token, password) => { const { data } = await api.put(`/auth/reset-password/${token}`, { - password, - confirmPassword: password, + password, confirmPassword: password, }); return data; }; const value = { - user, - loading, - login, - register, - googleLogin, - logout, - forgotPassword, - resetPassword, - clearAuth, + user, loading, + login, register, googleLogin, logout, + forgotPassword, resetPassword, + clearAuth, refreshUser, }; return {children}; @@ -116,8 +111,6 @@ export function AuthProvider({ children }) { export function useAuth() { const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } + if (!context) throw new Error('useAuth must be used within an AuthProvider'); return context; -} +} \ No newline at end of file diff --git a/client/src/context/NotificationContext.jsx b/client/src/context/NotificationContext.jsx new file mode 100644 index 0000000..b7c432f --- /dev/null +++ b/client/src/context/NotificationContext.jsx @@ -0,0 +1,102 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import api from '../api/axios'; +import { useSocket } from './SocketContext'; +import { useAuth } from './AuthContext'; + +const NotificationContext = createContext(null); + +export function NotificationProvider({ children }) { + const { socket, connected } = useSocket(); + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(false); + + const fetchUnreadCount = useCallback(async () => { + try { + const { data } = await api.get('/notifications/unread-count'); + setUnreadCount(data.count); + } catch { + // ignore + } + }, []); + + const fetchNotifications = useCallback(async (page = 1) => { + setLoading(true); + try { + const { data } = await api.get(`/notifications?page=${page}&limit=20`); + if (page === 1) { + setNotifications(data.notifications); + } else { + setNotifications(prev => [...prev, ...data.notifications]); + } + return data.pagination; + } catch { + return null; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!user) { + setNotifications([]); + setUnreadCount(0); + return; + } + fetchUnreadCount(); + fetchNotifications(); + }, [user, fetchUnreadCount, fetchNotifications]); + + useEffect(() => { + if (!socket || !connected) return; + + const handler = (notification) => { + setNotifications(prev => [notification, ...prev]); + setUnreadCount(prev => prev + 1); + }; + + socket.on('notification', handler); + return () => socket.off('notification', handler); + }, [socket, connected]); + + const markAsRead = useCallback(async (id) => { + try { + await api.patch(`/notifications/${id}/read`); + setNotifications(prev => + prev.map(n => n._id === id ? { ...n, read: true } : n) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + } catch { + // ignore + } + }, []); + + const markAllAsRead = useCallback(async () => { + try { + await api.put('/notifications/read-all'); + setNotifications(prev => prev.map(n => ({ ...n, read: true }))); + setUnreadCount(0); + } catch { + // ignore + } + }, []); + + return ( + + {children} + + ); +} + +export function useNotifications() { + return useContext(NotificationContext); +} diff --git a/client/src/context/SocketContext.jsx b/client/src/context/SocketContext.jsx new file mode 100644 index 0000000..42453ad --- /dev/null +++ b/client/src/context/SocketContext.jsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { io } from 'socket.io-client'; +import { useAuth } from './AuthContext'; + +const SocketContext = createContext(null); + +export function SocketProvider({ children }) { + const { user } = useAuth(); + const socketRef = useRef(null); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const token = sessionStorage.getItem('accessToken') || localStorage.getItem('accessToken'); + if (!user || !token) { + if (socketRef.current) { + socketRef.current.disconnect(); + socketRef.current = null; + setConnected(false); + } + return; + } + + const SOCKET_URL = 'http://localhost:3000'; + const socket = io(SOCKET_URL, { + auth: { token }, + transports: ['websocket', 'polling'], + }); + + socket.on('connect', () => setConnected(true)); + socket.on('disconnect', () => setConnected(false)); + socket.on('connect_error', () => setConnected(false)); + + socketRef.current = socket; + + return () => { + socket.disconnect(); + socketRef.current = null; + setConnected(false); + }; + }, [user]); + + return ( + + {children} + + ); +} + +export function useSocket() { + return useContext(SocketContext); +} diff --git a/client/src/pages/AdminArea.jsx b/client/src/pages/AdminArea.jsx new file mode 100644 index 0000000..0639dc4 --- /dev/null +++ b/client/src/pages/AdminArea.jsx @@ -0,0 +1,823 @@ +import { useState, useEffect } from 'react'; +import adminService from '../services/adminService'; +import api from '../api/axios'; +import { useAuth } from '../context/AuthContext'; +import ConfirmModal from '../components/ConfirmModal'; + +// Custom animated checkbox +const Checkbox = ({ checked, onChange }) => ( +
+ + + +
+); + +// Role badge +const RoleBadge = ({ role }) => { + const styles = { + super_admin: { background: 'rgba(234,179,8,0.15)', color: '#ca8a04', border: '1px solid rgba(234,179,8,0.3)' }, + admin: { background: 'rgba(99,102,241,0.1)', color: 'var(--accent)', border: '1px solid rgba(99,102,241,0.2)' }, + intern: { background: 'rgba(16,185,129,0.1)', color: 'var(--success)', border: '1px solid rgba(16,185,129,0.2)' }, + }; + return ( + {role} + ); +}; + +const ACTION_LABELS = { + promote_to_admin: 'Promoted to admin', + promote_to_super_admin: 'Promoted to super_admin', + demote_to_intern: 'Demoted to intern', + role_change: 'Role changed', + delete_user: 'User deleted', + create_faq: 'FAQ created', + update_faq: 'FAQ updated', + delete_faq: 'FAQ deleted', + promote_question_to_faq: 'Question promoted to FAQ', +}; + +const AdminArea = () => { + const { user: currentUser } = useAuth(); + const isSuperAdmin = currentUser?.role === 'super_admin'; + + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + const [questions, setQuestions] = useState([]); + const [users, setUsers] = useState([]); + const [queries, setQueries] = useState([]); + const [faqs, setFaqs] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); + const [pendingAnswers, setPendingAnswers] = useState([]); + const [pendingAnswersPagination, setPendingAnswersPagination] = useState(null); + const [answersPage, setAnswersPage] = useState(1); + const [answersLoading, setAnswersLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('overview'); + + // FAQ form state + const [faqForm, setFaqForm] = useState({ question: '', answer: '', category: '' }); + const [editingFaq, setEditingFaq] = useState(null); + const [faqLoading, setFaqLoading] = useState(false); + + const [selectedQuestions, setSelectedQuestions] = useState(new Set()); + const [selectedUsers, setSelectedUsers] = useState(new Set()); + const [selectedQueries, setSelectedQueries] = useState(new Set()); + const [respondModal, setRespondModal] = useState({ open: false, query: null, response: '' }); + const [toast, setToast] = useState(null); + const [confirm, setConfirm] = useState(null); + const waitConfirm = (opts) => new Promise(resolve => { setConfirm({ ...opts, resolve }); }); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (activeTab === 'faq manager' && isSuperAdmin) fetchFaqs(); + if (activeTab === 'audit log' && isSuperAdmin) fetchAuditLogs(); + if (activeTab === 'answer center') fetchPendingAnswers(); + }, [activeTab]); + + const fetchData = async () => { + try { + const statsData = await adminService.getStats(); + setStats(statsData); + const { default: questionService } = await import('../services/questionService'); + const qs = await questionService.getQuestions(); + setQuestions(qs); + const usersData = await adminService.getUsers(); + setUsers(usersData.users || []); + const queriesData = await api.get('/queries/all'); + setQueries(queriesData.data.queries || []); + } catch (err) { + setError(err.response?.data?.message || 'Error loading admin data'); + } finally { + setLoading(false); + } + }; + + const fetchPendingAnswers = async () => { + setAnswersLoading(true); + try { + const { data } = await api.get('/answers'); + setPendingAnswers(data || []); + } catch { setPendingAnswers([]); } + finally { setAnswersLoading(false); } +}; + + const handleApproveAnswer = async (id) => { + try { + await api.put(`/answers/${id}/approve`); + fetchPendingAnswers(answersPage); + } catch { alert('Failed to approve answer'); } + }; + + const handleRejectAnswer = async (id) => { + try { + await api.put(`/answers/${id}/reject`); + fetchPendingAnswers(answersPage); + } catch { alert('Failed to reject answer'); } + }; + + const fetchFaqs = async () => { + try { + const data = await adminService.getAllFaqs(); + setFaqs(data.faqs || []); + } catch { alert('Failed to load FAQs'); } + }; + + const fetchAuditLogs = async () => { + try { + const data = await adminService.getAuditLogs(); + setAuditLogs(data.logs || []); + } catch { alert('Failed to load audit logs'); } + }; + + const toggleSelect = (set, setFn, id) => { + const next = new Set(set); + next.has(id) ? next.delete(id) : next.add(id); + setFn(next); + }; + + const toggleAll = (items, set, setFn) => { + if (set.size === items.length) setFn(new Set()); + else setFn(new Set(items.map(i => i._id))); + }; + + const handleDeleteQuestion = async (id) => { + const ok = await waitConfirm({ title: 'Delete Question', message: 'Delete this question?', confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { + const { default: questionService } = await import('../services/questionService'); + await questionService.deleteQuestion(id); + fetchData(); + } catch { setToast({ type: 'error', message: 'Failed to delete question' }); } + }; + + const handleBulkDeleteQuestions = async () => { + if (!selectedQuestions.size) return; + const ok = await waitConfirm({ title: 'Delete Questions', message: `Delete ${selectedQuestions.size} question(s)?`, confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { + const { default: questionService } = await import('../services/questionService'); + await Promise.all([...selectedQuestions].map(id => questionService.deleteQuestion(id))); + setSelectedQuestions(new Set()); + fetchData(); + } catch { setToast({ type: 'error', message: 'Failed to delete some questions' }); } + }; + + const handleBulkDeleteUsers = async () => { + if (!selectedUsers.size) return; + const ok = await waitConfirm({ title: 'Delete Users', message: `Delete ${selectedUsers.size} user(s)?`, confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { + await Promise.all([...selectedUsers].map(id => adminService.deleteUser(id))); + setSelectedUsers(new Set()); + fetchData(); + } catch { setToast({ type: 'error', message: 'Failed to delete some users' }); } + }; + + const handleBulkDeleteQueries = async () => { + if (!selectedQueries.size) return; + const ok = await waitConfirm({ title: 'Delete Queries', message: `Delete ${selectedQueries.size} query(ies)?`, confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { + await Promise.all([...selectedQueries].map(id => api.delete(`/queries/${id}`))); + setSelectedQueries(new Set()); + fetchData(); + } catch { setToast({ type: 'error', message: 'Failed to delete some queries' }); } + }; + + const handlePromoteToFaq = async (q) => { + try { + await adminService.createFaq({ question: q.title, answer: q.title, category: 'general' }); + setToast({ type: 'success', message: 'Successfully promoted to FAQ!' }); + } catch { setToast({ type: 'error', message: 'Failed to promote to FAQ' }); } + }; + + const handlePromoteUser = async (id) => { + const ok = await waitConfirm({ title: 'Promote User', message: 'Promote this user to admin?', confirmLabel: 'Promote' }); + if (!ok) return; + try { await adminService.promoteUser(id); fetchData(); } + catch (err) { setToast({ type: 'error', message: err.response?.data?.message || 'Failed to promote user' }); } + }; + + const handlePromoteToSuperAdmin = async (id) => { + const ok = await waitConfirm({ title: 'Promote to Super Admin', message: 'Promote this user to super_admin?', confirmLabel: 'Promote' }); + if (!ok) return; + try { await adminService.promoteToSuperAdmin(id); fetchData(); } + catch (err) { setToast({ type: 'error', message: err.response?.data?.message || 'Failed' }); } + }; + + const handleDemoteAdmin = async (id) => { + const ok = await waitConfirm({ title: 'Demote User', message: 'Demote this user to intern?', confirmLabel: 'Demote', variant: 'danger' }); + if (!ok) return; + try { await adminService.demoteAdmin(id); fetchData(); } + catch (err) { setToast({ type: 'error', message: err.response?.data?.message || 'Failed to demote' }); } + }; + + const handleDeleteUser = async (id) => { + const ok = await waitConfirm({ title: 'Delete User', message: 'Delete this user? This cannot be undone.', confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { await adminService.deleteUser(id); fetchData(); } + catch (err) { setToast({ type: 'error', message: err.response?.data?.message || 'Failed to delete user' }); } + }; + + const handleResolveQuery = async (id, response) => { + try { await api.patch(`/queries/${id}/respond`, { response, status: 'resolved' }); fetchData(); } + catch { setToast({ type: 'error', message: 'Failed to resolve query' }); } + }; + + const handlePromoteQueryToFaq = async (q) => { + try { + await adminService.createFaq({ question: q.question, answer: q.adminResponse || q.question, category: 'general' }); + await api.delete(`/queries/${q._id}`); + setToast({ type: 'success', message: 'Promoted to FAQ and deleted from queries!' }); + fetchData(); + } catch { setToast({ type: 'error', message: 'Failed to promote query to FAQ' }); } + }; + + const handleDeleteQuery = async (id) => { + const ok = await waitConfirm({ title: 'Delete Query', message: 'Delete this query?', confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { await api.delete(`/queries/${id}`); fetchData(); } + catch { setToast({ type: 'error', message: 'Failed to delete query' }); } + }; + + // FAQ Manager handlers + const handleFaqSubmit = async () => { + if (!faqForm.question || !faqForm.answer || !faqForm.category) { + setToast({ type: 'error', message: 'All fields required' }); return; + } + setFaqLoading(true); + try { + if (editingFaq) { + await adminService.updateFaq(editingFaq._id, faqForm); + } else { + await adminService.createFaq(faqForm); + } + setFaqForm({ question: '', answer: '', category: '' }); + setEditingFaq(null); + fetchFaqs(); + } catch (err) { setToast({ type: 'error', message: err.response?.data?.message || 'Failed to save FAQ' }); } + finally { setFaqLoading(false); } + }; + + const handleEditFaq = (faq) => { + setEditingFaq(faq); + setFaqForm({ question: faq.question, answer: faq.answer, category: faq.category }); + }; + + const handleDeleteFaq = async (id) => { + const ok = await waitConfirm({ title: 'Delete FAQ', message: 'Delete this FAQ? It will be removed from search too.', confirmLabel: 'Delete', variant: 'danger' }); + if (!ok) return; + try { await adminService.deleteFaq(id); fetchFaqs(); } + catch { setToast({ type: 'error', message: 'Failed to delete FAQ' }); } + }; + + if (loading) return
Loading admin area...
; + if (error) return
{error}
; + + const openQueries = queries.filter(q => q.status === 'open'); + const tabs = ['overview', 'questions', 'users', 'unresolved queries', 'answer center', + ...(isSuperAdmin ? ['faq manager', 'audit log'] : []) + ]; + + const bulkBarStyle = (count) => ({ + display: count > 0 ? 'flex' : 'none', + alignItems: 'center', justifyContent: 'space-between', + background: 'var(--accent-light)', border: '1px solid var(--accent)', + borderRadius: 'var(--radius-sm)', padding: '10px 16px', marginBottom: 12 + }); + + const inputStyle = { + width: '100%', padding: '10px 14px', borderRadius: 'var(--radius-sm)', + border: '1px solid var(--border)', background: 'var(--bg-secondary)', + color: 'var(--text-primary)', fontSize: 14, fontFamily: 'inherit', + outline: 'none', boxSizing: 'border-box', + }; + + return ( +
+
+

Admin Dashboard

+

Manage users, questions, and content

+
+ + {/* Stats Cards */} + {stats && ( +
+ {[ + { label: 'Total Users', value: stats.totalUsers, tab: 'users', color: '#6366f1' }, + { label: 'Total Questions', value: stats.totalQuestions, tab: 'questions', color: '#f59e0b' }, + { label: 'Total Answers', value: stats.totalAnswers, tab: 'answer center', color: '#10b981' }, + { label: 'Total FAQs', value: stats.totalFaqs, tab: isSuperAdmin ? 'faq manager' : null, color: '#3b82f6' }, + { label: 'Unresolved', value: openQueries.length, tab: 'unresolved queries', color: '#ef4444' }, + ].map(({ label, value, tab, color }) => ( +
tab && setActiveTab(tab)} style={{ + background: 'var(--bg-card)', border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', padding: '20px 24px', + cursor: tab ? 'pointer' : 'default', transition: 'all 0.2s', + borderLeft: `4px solid ${color}` + }} + onMouseOver={e => { if (tab) e.currentTarget.style.borderColor = color; }} + onMouseOut={e => { if (tab) e.currentTarget.style.borderColor = 'var(--border)'; }}> +
{label}
+
{value}
+ {tab &&
Click to view β†’
} +
+ ))} +
+ )} + + {/* Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+
+

Recent Questions

+ {questions.slice(0, 3).map(q => ( +
+
{q.title}
+
Status: {q.status}
+
+ ))} + +
+
+

Recent Unresolved Queries

+ {openQueries.slice(0, 3).map(q => ( +
+
{q.question}
+
+ {q.user?.name || 'Unknown'} Β· {new Date(q.createdAt).toLocaleDateString()} +
+
+ ))} + {openQueries.length === 0 &&

No unresolved queries πŸŽ‰

} + +
+
+ )} + + {/* Questions Tab */} + {activeTab === 'questions' && ( +
+
+ {selectedQuestions.size} selected + +
+ {questions.length > 0 && ( +
+ toggleAll(questions, selectedQuestions, setSelectedQuestions)} /> + toggleAll(questions, selectedQuestions, setSelectedQuestions)}>Select all +
+ )} + {questions.length === 0 &&

No questions yet.

} + {questions.map(q => ( +
+
+ toggleSelect(selectedQuestions, setSelectedQuestions, q._id)} /> +
+
{q.title}
+
+ {q.status?.toUpperCase()} + {new Date(q.createdAt).toLocaleDateString()} +
+
+
+
+ + +
+
+ ))} +
+ )} + + {/* Users Tab */} + {activeTab === 'users' && ( +
+
+ {selectedUsers.size} selected + +
+ {users.length > 0 && ( +
+ toggleAll(users, selectedUsers, setSelectedUsers)} /> + toggleAll(users, selectedUsers, setSelectedUsers)}>Select all +
+ )} + {users.length === 0 &&

No users found.

} + {users.map(u => ( +
+
+ toggleSelect(selectedUsers, setSelectedUsers, u._id)} /> +
+ {u.name?.charAt(0)?.toUpperCase()} +
+
+
{u.name}
+
{u.email} · ⭐ {u.points || 0} pts
+
+
+
+ + {/* Admin can promote interns to admin */} + {u.role === 'intern' && ( + + )} + {/* Super admin extras */} + {isSuperAdmin && u.role === 'admin' && ( + <> + + + + )} + {isSuperAdmin && u.role === 'intern' && ( + + )} + {/* Can't delete super_admin, admin can't delete admin */} + {u.role !== 'super_admin' && (isSuperAdmin || u.role === 'intern') && ( + + )} +
+
+ ))} +
+ )} + + {/* Unresolved Queries Tab */} + {activeTab === 'unresolved queries' && ( +
+
+ {selectedQueries.size} selected + +
+ {queries.length > 0 && ( +
+ toggleAll(queries, selectedQueries, setSelectedQueries)} /> + toggleAll(queries, selectedQueries, setSelectedQueries)}>Select all +
+ )} + {queries.length === 0 &&

No unresolved queries πŸŽ‰

} + {queries.map(q => ( +
+
+
+ toggleSelect(selectedQueries, setSelectedQueries, q._id)} /> +
+
{q.question}
+
+ {q.status?.toUpperCase()} + {q.user?.name || 'Unknown'} Β· {new Date(q.createdAt).toLocaleDateString()} +
+
+
+
+ + {q.status !== 'resolved' && ( + + )} + +
+
+ {q.adminResponse && ( +
+ Admin response: {q.adminResponse} +
+ )} +
+ ))} +
+ )} + + {/* Answer Center Tab */} + {activeTab === 'answer center' && ( +
+ {answersLoading &&

Loading...

} + {!answersLoading && pendingAnswers.length === 0 && ( +

No answers yet.

+ )} + {pendingAnswers.map(a => ( +
+
+
+ {a.isAccepted && ( + + βœ“ Accepted + + )} +
+ {a.author?.name || 'Unknown'} + ⭐ {a.author?.points || 0} pts + +
+
+ {a.author?.email} Β· {new Date(a.createdAt).toLocaleString()} +
+
+ {a.content?.slice(0, 300)}{a.content?.length > 300 ? '...' : ''} +
+
+ On: {a.question?.title || 'Deleted question'} + + β–² {a.upvotes?.length || 0} + β–Ό {a.downvotes?.length || 0} + +
+
+
+ {!a.isAccepted && ( + + )} + + {a.author?.role !== 'super_admin' && ( + + )} +
+
+
+ ))} +
+)} + + {/* FAQ Manager Tab (super_admin only) */} + {activeTab === 'faq manager' && isSuperAdmin && ( +
+ {/* Form */} +
+

{editingFaq ? 'Edit FAQ' : 'Add New FAQ'}

+
+ setFaqForm(f => ({ ...f, question: e.target.value }))} /> +