diff --git a/src/App.js b/src/App.js index 074e4fd..ef8b6a7 100644 --- a/src/App.js +++ b/src/App.js @@ -17,6 +17,7 @@ import Logout from "./app/pages/Logout"; import Stats from './app/pages/Stats'; import Profile from './app/pages/Profile'; import Friends from './app/pages/Friends'; +import Admin from './admin/Admin'; // Demo Pages import DemoNavbar from "./demo/components/DemoNavbar"; import DemoBanner from "./demo/components/DemoBanner"; @@ -30,6 +31,8 @@ import axios from 'axios'; const constants = require('./app/constants'); const theme = require('./styling/theme'); +const ADMIN_EMAILS = (process.env.REACT_APP_ADMIN_EMAILS || '').split(',').map(e => e.trim()).filter(Boolean); + const App = () => { axios.defaults.withCredentials = true; const [user, setUser] = useState(null); @@ -135,7 +138,7 @@ function AppContent({ user, setUserChanged, newTypes, selectedTags, setSelectedT return (
{showNormalNavbar && ( - + )} {showDemoNavbarWithSignIn && ( @@ -148,6 +151,7 @@ function AppContent({ user, setUserChanged, newTypes, selectedTags, setSelectedT : } /> : } /> : } /> + : } /> } /> } /> } /> diff --git a/src/admin/Admin.css b/src/admin/Admin.css new file mode 100644 index 0000000..d81e659 --- /dev/null +++ b/src/admin/Admin.css @@ -0,0 +1,234 @@ +.admin-dashboard { + min-height: 100vh; + background-color: #2c3e50; + color: #ffffff; + padding: 32px 24px; +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 32px; +} + +.admin-header h1 { + font-size: 1.75rem; + font-weight: 700; + margin: 0; + color: #ffc107; +} + +.admin-range-select { + background-color: #34495e; + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 8px 12px; + font-size: 0.9rem; + cursor: pointer; + outline: none; + transition: border-color 0.2s ease; +} + +.admin-range-select:focus { + border-color: #ffc107; +} + +.admin-stat-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 40px; +} + +.admin-stat-card { + background-color: #34495e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 20px 24px; + text-align: center; +} + +.admin-stat-card .stat-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ca3af; + margin-bottom: 8px; +} + +.admin-stat-card .stat-value { + font-size: 2rem; + font-weight: 700; + color: #ffc107; + line-height: 1.1; +} + +.admin-charts { + display: flex; + flex-direction: column; + gap: 40px; +} + +.admin-chart-block { + background-color: #34495e; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 24px; +} + +.admin-chart-block h2 { + font-size: 1rem; + font-weight: 600; + color: #e5e7eb; + margin-bottom: 20px; +} + +.admin-chart-wrapper { + height: 260px; + position: relative; +} + +.admin-users-block { + display: flex; + flex-direction: column; + gap: 0; +} + +.admin-users-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.admin-users-header h2 { + margin-bottom: 0; +} + +.admin-users-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-users-page-info { + font-size: 0.8rem; + color: #9ca3af; +} + +.admin-users-loading { + display: flex; + justify-content: center; + padding: 24px 0; +} + +.admin-users-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.admin-users-table th, +.admin-users-table td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-users-table th { + color: #9ca3af; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + background-color: rgba(0, 0, 0, 0.15); + white-space: nowrap; +} + +.admin-users-table td { + color: #e5e7eb; +} + +.admin-users-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.04); +} + +.admin-users-table th:first-child, +.admin-users-table td:first-child { + width: 48px; + color: #9ca3af; +} + +.admin-sort-col { + cursor: pointer; + user-select: none; +} + +.admin-sort-col:hover { + color: #ffc107; +} + +.admin-sort-icon { + margin-left: 4px; + font-size: 0.7rem; + color: #ffc107; +} + +.admin-sort-icon.inactive { + color: #6b7280; +} + +.admin-no-email { + color: #6b7280; +} + +.admin-total-records { + text-align: right; + padding-right: 20px; + font-variant-numeric: tabular-nums; +} + +td.admin-total-records { + color: #ffc107; +} + +.admin-page-btn { + background-color: #34495e; + color: #e5e7eb; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 6px 16px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.admin-page-btn:hover:not(:disabled) { + background-color: rgba(255, 193, 7, 0.15); + border-color: #ffc107; + color: #ffc107; +} + +.admin-page-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.admin-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background-color: #2c3e50; +} + +.admin-loading-text { + color: #9ca3af; + margin-top: 12px; +} diff --git a/src/admin/Admin.jsx b/src/admin/Admin.jsx new file mode 100644 index 0000000..34dc0ad --- /dev/null +++ b/src/admin/Admin.jsx @@ -0,0 +1,322 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import './Admin.css'; + +const constants = require('../app/constants'); + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend +); + +const LINE_COLOR = '#ffc107'; +const GRID_COLOR = 'rgba(229, 231, 235, 0.15)'; +const TICK_COLOR = '#e5e7eb'; + +function buildLineData(label, entries, xKey, color) { + return { + labels: entries.map(e => e[xKey]), + datasets: [ + { + label, + data: entries.map(e => e.count), + borderColor: color, + backgroundColor: color + '33', + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 6, + fill: true, + }, + ], + }; +} + +function buildLineOptions(yTitle) { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + title: { display: false }, + }, + scales: { + y: { + beginAtZero: true, + ticks: { color: TICK_COLOR, stepSize: 1 }, + title: { display: true, text: yTitle, color: TICK_COLOR }, + grid: { color: GRID_COLOR }, + }, + x: { + ticks: { color: TICK_COLOR, maxRotation: 45, minRotation: 0 }, + grid: { color: GRID_COLOR }, + }, + }, + }; +} + +function avg(arr) { + if (!arr.length) return 0; + return Math.round(arr.reduce((s, d) => s + d.count, 0) / arr.length); +} + +function formatDate(iso) { + if (!iso) return ; + return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function UsersTable() { + const [page, setPage] = useState(1); + const [sort, setSort] = useState('lastActiveAt'); + const [order, setOrder] = useState('desc'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + axios.get( + `${constants.SERVER_URL}/api/admin/users?page=${page}&sort=${sort}&order=${order}`, + { withCredentials: true } + ) + .then(res => { if (res.data.success) setData(res.data); }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [page, sort, order]); + + const SORT_FIELDS = ['lastActiveAt', 'createdAt', 'totalRecords']; + + const handleSort = (field) => { + if (!SORT_FIELDS.includes(field)) return; + if (sort === field) { + setOrder(o => o === 'desc' ? 'asc' : 'desc'); + } else { + setSort(field); + setOrder('desc'); + } + setPage(1); + }; + + const sortIcon = (field) => { + if (sort !== field) return ; + return {order === 'desc' ? '↓' : '↑'}; + }; + + return ( +
+
+

All Users

+
+ {data && ( + + Page {data.page} of {data.totalPages} ({data.total} total) + + )} + + +
+
+ {loading ? ( +
+
+ Loading... +
+
+ ) : ( + + + + + + + + + + + + + {data?.users.map((u, i) => ( + + + + + + + + + ))} + +
#Display NameEmail handleSort('lastActiveAt')} + > + Last Active {sortIcon('lastActiveAt')} + handleSort('createdAt')} + > + Joined {sortIcon('createdAt')} + handleSort('totalRecords')} + > + Total Records {sortIcon('totalRecords')} +
{(data.page - 1) * 10 + i + 1}{u.displayName}{u.email || }{formatDate(u.lastActiveAt)}{formatDate(u.createdAt)}{u.totalRecords ?? 0}
+ )} +
+ ); +} + +const Admin = () => { + const [range, setRange] = useState(30); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + setError(null); + try { + const res = await axios.get( + `${constants.SERVER_URL}/api/admin/stats?range=${range}`, + { withCredentials: true } + ); + if (res.data.success) { + setStats(res.data); + } else { + setError('Failed to load admin stats.'); + } + } catch (err) { + if (err.response?.status === 403) { + setError('Access denied.'); + } else { + setError('Error loading admin stats.'); + } + } finally { + setLoading(false); + } + }; + fetchStats(); + }, [range]); + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading dashboard...

+
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + const dauData = buildLineData('Daily Active Users', stats.dailyActiveUsers, 'date', LINE_COLOR); + const mauData = buildLineData('Monthly Active Users', stats.monthlyActiveUsers, 'month', '#4ECDC4'); + const signupsData = buildLineData('New Signups', stats.newSignupsPerDay, 'date', '#74b9ff'); + + const avgDAU = avg(stats.dailyActiveUsers); + const avgMAU = avg(stats.monthlyActiveUsers); + const totalSignups = stats.newSignupsPerDay.reduce((s, d) => s + d.count, 0); + + return ( +
+
+

Admin Dashboard

+ +
+ +
+
+
Total Users
+
{stats.totalUsers.toLocaleString()}
+
+
+
Avg Daily Active
+
{avgDAU.toLocaleString()}
+
+
+
Avg Monthly Active
+
{avgMAU.toLocaleString()}
+
+
+
New Signups (range)
+
{totalSignups.toLocaleString()}
+
+
+ +
+ + +
+

Daily Active Users

+
+ +
+
+ +
+

Monthly Active Users

+
+ +
+
+ +
+

New Signups Per Day

+
+ +
+
+
+
+ ); +}; + +export default Admin; diff --git a/src/app/Navbar.jsx b/src/app/Navbar.jsx index 5d4b2b0..7b4a5d4 100644 --- a/src/app/Navbar.jsx +++ b/src/app/Navbar.jsx @@ -7,7 +7,7 @@ import { useClickOutside } from './hooks/useClickOutside'; const constants = require('./constants'); const theme = require('../styling/theme'); -const NavbarFunction = ({user, setUserChanged, newTypes}) => { +const NavbarFunction = ({user, setUserChanged, newTypes, isAdmin}) => { const [showModal, setShowModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const navigate = useNavigate(); @@ -61,10 +61,11 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => { // Calculate dynamic width for mobile profile dropdown const calculateProfileDropdownWidth = useCallback(() => { const labels = ['Export All Data', 'Import New List', 'View Profile', 'Switch Account', 'Logout']; + if (isAdmin) labels.push('Admin Board'); const longestLabel = labels.reduce((a, b) => a.length > b.length ? a : b); const estimatedWidth = longestLabel.length * 6 + 24; return Math.max(estimatedWidth, 100); - }, []); + }, [isAdmin]); // Calculate dynamic width for mobile media dropdown const calculateMediaDropdownWidth = useCallback(() => { @@ -98,10 +99,11 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => { // Calculate dynamic width for desktop profile dropdown const calculateDesktopProfileDropdownWidth = useCallback(() => { const labels = ['Export All Data', 'Import New List', 'View Profile', 'Switch Google Account', 'Logout']; + if (isAdmin) labels.push('Admin Board'); const longestLabel = labels.reduce((a, b) => a.length > b.length ? a : b); const estimatedWidth = longestLabel.length * 7 + 32; return Math.max(estimatedWidth, 120); - }, []); + }, [isAdmin]); const showNewTypeModal = () => setShowModal(true); @@ -456,9 +458,31 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => { background: 'none', border: 'none', cursor: 'pointer' - }} className="navbar-dropdown-item" onClick={exportAllData}> - Export All Data + }} className="navbar-dropdown-item" onClick={() => { + setIsUserMenuOpen(false); + navigate('/profile'); + }}> + View Profile + {isAdmin && ( + + )} +
+
- - About - -
{ background: 'none', border: 'none', cursor: 'pointer' - }} className="navbar-dropdown-item" onClick={exportAllData}> - Export All Data + }} className="navbar-dropdown-item" onClick={() => { + setIsUserMenuOpen(false); + navigate('/profile'); + }}> + View Profile + {isAdmin && ( + + )} +
+
-
{ color: theme.components.navbar.colors.dropdownItemText, textDecoration: 'none' }} className="navbar-dropdown-item"> - Logout + Logout
)}