easy-starter 1.2.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -80
- package/bin/index.js +219 -44
- package/package.json +20 -11
- package/template/.cursorrules +16 -9
- package/template/README.md +81 -24
- package/template/env +0 -1
- package/template/gitignore +1 -1
- package/template/index.html +15 -0
- package/template/package.json +8 -9
- package/template/public/images/icon-16.png +0 -0
- package/template/public/images/icon-180.png +0 -0
- package/template/public/images/icon-192.png +0 -0
- package/template/public/images/icon-32.png +0 -0
- package/template/public/images/icon-512.png +0 -0
- package/template/{src → public}/site.webmanifest +6 -12
- package/template/server/index.tsx +249 -53
- package/template/server/mail.tsx +120 -0
- package/template/src/App.tsx +33 -7
- package/template/src/Router.tsx +11 -2
- package/template/src/index.tsx +28 -1
- package/template/src/pages/Index.tsx +45 -6
- package/template/src/pages/ResetPassword.tsx +126 -0
- package/template/src/pages/admin/Admin.tsx +360 -85
- package/template/src/pages/admin/AnalyticsTab.tsx +71 -0
- package/template/src/pages/admin/ErrorsTab.tsx +46 -0
- package/template/src/pages/admin/UsersTab.tsx +61 -0
- package/template/src/services/api.ts +5 -7
- package/template/src/services/common.ts +23 -0
- package/template/src/styles.css +460 -0
- package/template/tsconfig.json +2 -1
- package/template/types.ts +44 -3
- package/template/vite.config.ts +24 -0
- package/template/.parcelrc +0 -6
- package/template/src/images/icon.png +0 -0
- package/template/src/index.html +0 -16
- package/template/src/styles.scss +0 -292
|
@@ -1,20 +1,218 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
|
-
import { useGroup, GroupTable, getGroupedCount } from "easy-analytics/react";
|
|
3
2
|
import { Link } from "easy-page-router/react";
|
|
4
3
|
|
|
5
4
|
import { useHead } from "../../services/common";
|
|
6
5
|
import { api, handleError } from "../../services/api";
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// --- ANALYTICS START ---
|
|
8
|
+
import AnalyticsTab from "./AnalyticsTab";
|
|
9
|
+
// --- ANALYTICS END ---
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// --- ADMIN_ERRORS START ---
|
|
12
|
+
import ErrorsTab from "./ErrorsTab";
|
|
13
|
+
// --- ADMIN_ERRORS END ---
|
|
14
|
+
|
|
15
|
+
// --- ADMIN_USERS START ---
|
|
16
|
+
import UsersTab from "./UsersTab";
|
|
17
|
+
// --- ADMIN_USERS END ---
|
|
18
|
+
|
|
19
|
+
// --- AUTH START ---
|
|
20
|
+
import { useUser } from "easy-user-auth/react";
|
|
21
|
+
import { UserProfile } from "../../../types";
|
|
22
|
+
// --- AUTH END ---
|
|
23
|
+
|
|
24
|
+
type AdminProps = {
|
|
25
|
+
path: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default function Admin({ path }: AdminProps) {
|
|
29
|
+
useHead({ title: "Admin Dashboard", description: "Admin section for managing the application." });
|
|
14
30
|
|
|
15
31
|
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7));
|
|
16
32
|
const [records, setRecords] = useState([]);
|
|
17
33
|
const [loading, setLoading] = useState(false);
|
|
34
|
+
const [error, setError] = useState("");
|
|
35
|
+
|
|
36
|
+
const defaultTab =
|
|
37
|
+
// --- ANALYTICS START ---
|
|
38
|
+
"analytics"
|
|
39
|
+
// --- ANALYTICS END ---
|
|
40
|
+
// --- NO_ANALYTICS START ---
|
|
41
|
+
// --- ADMIN_ERRORS START ---
|
|
42
|
+
"errors"
|
|
43
|
+
// --- ADMIN_ERRORS END ---
|
|
44
|
+
// --- NO_ADMIN_ERRORS START ---
|
|
45
|
+
// --- ADMIN_USERS START ---
|
|
46
|
+
"users"
|
|
47
|
+
// --- ADMIN_USERS END ---
|
|
48
|
+
// --- NO_ADMIN_USERS START ---
|
|
49
|
+
"dashboard"
|
|
50
|
+
// --- NO_ADMIN_USERS END ---
|
|
51
|
+
// --- NO_ADMIN_ERRORS END ---
|
|
52
|
+
// --- NO_ANALYTICS END ---
|
|
53
|
+
;
|
|
54
|
+
|
|
55
|
+
const activeTab = path[1] || defaultTab;
|
|
56
|
+
|
|
57
|
+
// --- ADMIN_ERRORS START ---
|
|
58
|
+
const [errorsList, setErrorsList] = useState<any[]>([]);
|
|
59
|
+
const [loadingErrors, setLoadingErrors] = useState(false);
|
|
60
|
+
const [errorLogsError, setErrorLogsError] = useState("");
|
|
61
|
+
// --- ADMIN_ERRORS END ---
|
|
62
|
+
|
|
63
|
+
// --- ADMIN_USERS START ---
|
|
64
|
+
const [usersList, setUsersList] = useState<any[]>([]);
|
|
65
|
+
const [loadingUsers, setLoadingUsers] = useState(false);
|
|
66
|
+
const [usersError, setUsersError] = useState("");
|
|
67
|
+
// --- ADMIN_USERS END ---
|
|
68
|
+
|
|
69
|
+
// --- ADMIN_WITH_AUTH START ---
|
|
70
|
+
const { isLoggedIn, user, loginUser, showUserDialog } = useUser<UserProfile>();
|
|
71
|
+
|
|
72
|
+
// --- ADMIN_USERS START ---
|
|
73
|
+
const fetchUsers = async () => {
|
|
74
|
+
setLoadingUsers(true);
|
|
75
|
+
setUsersError("");
|
|
76
|
+
try {
|
|
77
|
+
const [data, err] = await api.getUsers({});
|
|
78
|
+
if (data) {
|
|
79
|
+
setUsersList(data);
|
|
80
|
+
} else if (err) {
|
|
81
|
+
setUsersError(err.message || String(err));
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
setUsersError(String(e));
|
|
85
|
+
}
|
|
86
|
+
setLoadingUsers(false);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleRoleChange = async (userId: string, newRole: "admin" | "user") => {
|
|
90
|
+
try {
|
|
91
|
+
const [success, err] = await api.updateUserRole({ userId, role: newRole });
|
|
92
|
+
if (success) {
|
|
93
|
+
setUsersList(prev => prev.map(u => u._id === userId ? { ...u, role: newRole } : u));
|
|
94
|
+
} else if (err) {
|
|
95
|
+
alert("Failed to update role: " + err.message);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
alert(String(e));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
// --- ADMIN_USERS END ---
|
|
102
|
+
|
|
103
|
+
// --- ADMIN_ERRORS_WITH_AUTH START ---
|
|
104
|
+
const fetchErrors = async (m: string) => {
|
|
105
|
+
setLoadingErrors(true);
|
|
106
|
+
setErrorLogsError("");
|
|
107
|
+
try {
|
|
108
|
+
const [data, err] = await api.getEasyAnalyticsErrors({ month: m });
|
|
109
|
+
if (data) {
|
|
110
|
+
setErrorsList(data);
|
|
111
|
+
} else if (err) {
|
|
112
|
+
setErrorLogsError(err.message || String(err));
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
setErrorLogsError(String(e));
|
|
116
|
+
}
|
|
117
|
+
setLoadingErrors(false);
|
|
118
|
+
};
|
|
119
|
+
// --- ADMIN_ERRORS_WITH_AUTH END ---
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (isLoggedIn && user?.role === "admin") {
|
|
123
|
+
if (activeTab === "analytics") {
|
|
124
|
+
fetchData(month);
|
|
125
|
+
}
|
|
126
|
+
// --- ADMIN_ERRORS START ---
|
|
127
|
+
if (activeTab === "errors") {
|
|
128
|
+
fetchErrors(month);
|
|
129
|
+
}
|
|
130
|
+
// --- ADMIN_ERRORS END ---
|
|
131
|
+
// --- ADMIN_USERS START ---
|
|
132
|
+
if (activeTab === "users") {
|
|
133
|
+
fetchUsers();
|
|
134
|
+
}
|
|
135
|
+
// --- ADMIN_USERS END ---
|
|
136
|
+
}
|
|
137
|
+
}, [isLoggedIn, user, month, activeTab]);
|
|
138
|
+
|
|
139
|
+
const fetchData = async (m: string) => {
|
|
140
|
+
setLoading(true);
|
|
141
|
+
setError("");
|
|
142
|
+
try {
|
|
143
|
+
// --- ANALYTICS START ---
|
|
144
|
+
const [data, err] = await api.getEasyAnalyticsData({ month: m });
|
|
145
|
+
if (data) {
|
|
146
|
+
setRecords(data as any);
|
|
147
|
+
} else if (err) {
|
|
148
|
+
setError(err.message || String(err));
|
|
149
|
+
}
|
|
150
|
+
// --- ANALYTICS END ---
|
|
151
|
+
} catch (e) {
|
|
152
|
+
setError(String(e));
|
|
153
|
+
handleError(e as Error);
|
|
154
|
+
}
|
|
155
|
+
setLoading(false);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleMonthChange = (move: number) => {
|
|
159
|
+
const newMonth = new Date(month);
|
|
160
|
+
newMonth.setMonth(newMonth.getMonth() + move);
|
|
161
|
+
const nextM = newMonth.toISOString().slice(0, 7);
|
|
162
|
+
setMonth(nextM);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (!isLoggedIn || user?.role !== "admin") {
|
|
166
|
+
return (
|
|
167
|
+
<div className="admin-login" style={{ padding: 40, textAlign: 'center' }}>
|
|
168
|
+
{!isLoggedIn ? (
|
|
169
|
+
<>
|
|
170
|
+
<h2>Admin Login Required</h2>
|
|
171
|
+
<p>Please log in using an account with administrative permissions.</p>
|
|
172
|
+
<div style={{ display: 'flex', gap: 15, justifyContent: 'center', marginTop: 20 }}>
|
|
173
|
+
<button onClick={showUserDialog}>Log In</button>
|
|
174
|
+
<Link href="/">Back to website</Link>
|
|
175
|
+
</div>
|
|
176
|
+
<div style={{ marginTop: 30, fontSize: '0.85em', color: 'var(--text-muted)' }}>
|
|
177
|
+
💡 <strong>Tip:</strong> You can create the first admin by manually changing <code>"role": "user"</code> to <code>"role": "admin"</code> in <code>easy-db/user.json</code> for your user record.
|
|
178
|
+
</div>
|
|
179
|
+
</>
|
|
180
|
+
) : (
|
|
181
|
+
<>
|
|
182
|
+
<h2>Access Denied</h2>
|
|
183
|
+
<p>You do not have administrative permissions. (Logged in as <strong>{user?.name || user?.email}</strong>)</p>
|
|
184
|
+
<div style={{ display: 'flex', gap: 15, justifyContent: 'center', marginTop: 20 }}>
|
|
185
|
+
<button onClick={() => loginUser(null)}>Log Out</button>
|
|
186
|
+
<Link href="/">Back to website</Link>
|
|
187
|
+
</div>
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// --- ADMIN_WITH_AUTH END ---
|
|
194
|
+
|
|
195
|
+
// --- ADMIN_NO_AUTH START ---
|
|
196
|
+
const [password, setPassword] = useState("");
|
|
197
|
+
const [loggedIn, setLoggedIn] = useState(false);
|
|
198
|
+
|
|
199
|
+
// --- ADMIN_ERRORS_NO_AUTH START ---
|
|
200
|
+
const fetchErrors = async (pwd: string, m: string) => {
|
|
201
|
+
setLoadingErrors(true);
|
|
202
|
+
setErrorLogsError("");
|
|
203
|
+
try {
|
|
204
|
+
const [data, err] = await api.getEasyAnalyticsErrors({ password: pwd, month: m });
|
|
205
|
+
if (data) {
|
|
206
|
+
setErrorsList(data);
|
|
207
|
+
} else if (err) {
|
|
208
|
+
setErrorLogsError(err.message || String(err));
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
setErrorLogsError(String(e));
|
|
212
|
+
}
|
|
213
|
+
setLoadingErrors(false);
|
|
214
|
+
};
|
|
215
|
+
// --- ADMIN_ERRORS_NO_AUTH END ---
|
|
18
216
|
|
|
19
217
|
useEffect(() => {
|
|
20
218
|
const savedPwd = localStorage.getItem("admin_pwd");
|
|
@@ -28,17 +226,31 @@ export default function Admin() {
|
|
|
28
226
|
setLoading(true);
|
|
29
227
|
setError("");
|
|
30
228
|
try {
|
|
229
|
+
// --- ANALYTICS START ---
|
|
31
230
|
const [data, err] = await api.getEasyAnalyticsData({ password: pwd, month: m });
|
|
32
|
-
|
|
33
231
|
if (data) {
|
|
34
232
|
setRecords(data as any);
|
|
35
233
|
setLoggedIn(true);
|
|
36
234
|
localStorage.setItem("admin_pwd", pwd);
|
|
37
235
|
} else if (err) {
|
|
38
|
-
setError(err);
|
|
236
|
+
setError(err.message || String(err));
|
|
237
|
+
setLoggedIn(false);
|
|
238
|
+
localStorage.removeItem("admin_pwd");
|
|
239
|
+
}
|
|
240
|
+
// --- ANALYTICS END ---
|
|
241
|
+
// --- NO_ANALYTICS START ---
|
|
242
|
+
// --- ADMIN_ERRORS START ---
|
|
243
|
+
const [data, err] = await api.getEasyAnalyticsErrors({ password: pwd, month: m });
|
|
244
|
+
if (data || !err) {
|
|
245
|
+
setLoggedIn(true);
|
|
246
|
+
localStorage.setItem("admin_pwd", pwd);
|
|
247
|
+
} else {
|
|
248
|
+
setError(err.message || String(err));
|
|
39
249
|
setLoggedIn(false);
|
|
40
250
|
localStorage.removeItem("admin_pwd");
|
|
41
251
|
}
|
|
252
|
+
// --- ADMIN_ERRORS END ---
|
|
253
|
+
// --- NO_ANALYTICS END ---
|
|
42
254
|
} catch (e) {
|
|
43
255
|
setError(String(e));
|
|
44
256
|
handleError(e as Error);
|
|
@@ -46,6 +258,19 @@ export default function Admin() {
|
|
|
46
258
|
setLoading(false);
|
|
47
259
|
};
|
|
48
260
|
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (loggedIn) {
|
|
263
|
+
if (activeTab === "analytics") {
|
|
264
|
+
fetchData(password, month);
|
|
265
|
+
}
|
|
266
|
+
// --- ADMIN_ERRORS START ---
|
|
267
|
+
if (activeTab === "errors") {
|
|
268
|
+
fetchErrors(password, month);
|
|
269
|
+
}
|
|
270
|
+
// --- ADMIN_ERRORS END ---
|
|
271
|
+
}
|
|
272
|
+
}, [loggedIn, password, month, activeTab]);
|
|
273
|
+
|
|
49
274
|
const handleLogin = (e: React.FormEvent) => {
|
|
50
275
|
e.preventDefault();
|
|
51
276
|
fetchData(password, month);
|
|
@@ -56,112 +281,162 @@ export default function Admin() {
|
|
|
56
281
|
newMonth.setMonth(newMonth.getMonth() + move);
|
|
57
282
|
const nextM = newMonth.toISOString().slice(0, 7);
|
|
58
283
|
setMonth(nextM);
|
|
59
|
-
if (loggedIn)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
284
|
+
if (loggedIn) {
|
|
285
|
+
if (activeTab === "analytics") {
|
|
286
|
+
fetchData(password, nextM);
|
|
287
|
+
}
|
|
288
|
+
// --- ADMIN_ERRORS START ---
|
|
289
|
+
if (activeTab === "errors") {
|
|
290
|
+
fetchErrors(password, nextM);
|
|
291
|
+
}
|
|
292
|
+
// --- ADMIN_ERRORS END ---
|
|
67
293
|
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const byReferrer = useGroup(records, (r: any) => r.referrer || "Direct", (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
71
|
-
|
|
72
|
-
const byWidth = useGroup(records, (r: any) => {
|
|
73
|
-
const w = r.window?.innerWidth || 0;
|
|
74
|
-
if (w >= 1920) return ">= 1920px Full HD";
|
|
75
|
-
if (w >= 1280) return "1280px - 1920px HD";
|
|
76
|
-
if (w >= 768) return "768px - 1280px Tablet";
|
|
77
|
-
if (w > 0) return "< 768px Mobile";
|
|
78
|
-
return "Unknown";
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Simplified UA parser - for production consider using a library like 'ua-parser-js'
|
|
82
|
-
const byBrowserLike = useGroup(records, (r: any) => {
|
|
83
|
-
const ua = (r.userAgent || "").toLowerCase();
|
|
84
|
-
if (ua.includes("firefox")) return "Firefox";
|
|
85
|
-
if (ua.includes("edg")) return "Edge";
|
|
86
|
-
if (ua.includes("chrome")) return "Chrome";
|
|
87
|
-
if (ua.includes("safari")) return "Safari";
|
|
88
|
-
return "Other/Unknown";
|
|
89
|
-
}, (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
294
|
+
};
|
|
90
295
|
|
|
91
296
|
if (!loggedIn) {
|
|
92
297
|
return (
|
|
93
|
-
<div className="admin-login">
|
|
94
|
-
<h2>
|
|
95
|
-
<
|
|
96
|
-
<Link href="/">Back to website</Link>
|
|
97
|
-
</div>
|
|
98
|
-
<form onSubmit={handleLogin}>
|
|
298
|
+
<div className="admin-login" style={{ padding: 40, textAlign: 'center' }}>
|
|
299
|
+
<h2>Admin Login Required</h2>
|
|
300
|
+
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 300, margin: '20px auto' }}>
|
|
99
301
|
<input
|
|
100
302
|
type="password"
|
|
101
303
|
placeholder="Enter admin password"
|
|
102
304
|
value={password}
|
|
103
305
|
onChange={e => setPassword(e.target.value)}
|
|
306
|
+
style={{ padding: 10, background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--text)', borderRadius: 4 }}
|
|
307
|
+
required
|
|
104
308
|
/>
|
|
105
|
-
<button type="submit">
|
|
106
|
-
|
|
309
|
+
<button type="submit" disabled={loading} style={{ padding: 10, background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
|
310
|
+
{loading ? "Logging in..." : "Login"}
|
|
311
|
+
</button>
|
|
107
312
|
</form>
|
|
313
|
+
{error && <p style={{ color: "#ef4444" }}>{error}</p>}
|
|
314
|
+
<div style={{ marginTop: 20 }}>
|
|
315
|
+
<Link href="/">Back to website</Link>
|
|
316
|
+
</div>
|
|
108
317
|
</div>
|
|
109
318
|
);
|
|
110
319
|
}
|
|
111
|
-
|
|
112
|
-
// Daily visits counter
|
|
113
|
-
const daysInMonth = new Date(parseInt(month.slice(0, 4)), parseInt(month.slice(5, 7)), 0).getDate();
|
|
114
|
-
const dailyStats = Array.from({ length: daysInMonth }, (_, i) => {
|
|
115
|
-
const d = i + 1;
|
|
116
|
-
const dateStr = `${month}-${d.toString().padStart(2, "0")}`;
|
|
117
|
-
const count = records.filter((r: any) => r.serverDate && r.serverDate.startsWith(dateStr)).length;
|
|
118
|
-
return { date: d, count };
|
|
119
|
-
});
|
|
120
|
-
const maxVisits = Math.max(...dailyStats.map(s => s.count), 1);
|
|
320
|
+
// --- ADMIN_NO_AUTH END ---
|
|
121
321
|
|
|
122
322
|
return (
|
|
123
323
|
<div className="admin-container">
|
|
124
324
|
<div className="header">
|
|
125
|
-
<h2>
|
|
325
|
+
<h2>Admin Dashboard</h2>
|
|
126
326
|
<Link href="/">Back to website</Link>
|
|
127
327
|
</div>
|
|
128
328
|
|
|
329
|
+
<div className="admin-tabs" style={{ display: 'flex', gap: 10, margin: '20px 0', borderBottom: '1px solid var(--border)', paddingBottom: 10 }}>
|
|
330
|
+
{/* --- ANALYTICS START --- */}
|
|
331
|
+
<Link
|
|
332
|
+
href="/admin/analytics"
|
|
333
|
+
style={{
|
|
334
|
+
padding: '8px 16px',
|
|
335
|
+
background: activeTab === "analytics" ? 'var(--primary)' : 'transparent',
|
|
336
|
+
color: activeTab === "analytics" ? '#fff' : 'var(--text-muted)',
|
|
337
|
+
border: 'none',
|
|
338
|
+
borderRadius: 4,
|
|
339
|
+
cursor: 'pointer',
|
|
340
|
+
fontWeight: 'bold',
|
|
341
|
+
textDecoration: 'none'
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
Analytics
|
|
345
|
+
</Link>
|
|
346
|
+
{/* --- ANALYTICS END --- */}
|
|
347
|
+
{/* --- ADMIN_ERRORS START --- */}
|
|
348
|
+
<Link
|
|
349
|
+
href="/admin/errors"
|
|
350
|
+
style={{
|
|
351
|
+
padding: '8px 16px',
|
|
352
|
+
background: activeTab === "errors" ? 'var(--primary)' : 'transparent',
|
|
353
|
+
color: activeTab === "errors" ? '#fff' : 'var(--text-muted)',
|
|
354
|
+
border: 'none',
|
|
355
|
+
borderRadius: 4,
|
|
356
|
+
cursor: 'pointer',
|
|
357
|
+
fontWeight: 'bold',
|
|
358
|
+
textDecoration: 'none'
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
Error Logs
|
|
362
|
+
</Link>
|
|
363
|
+
{/* --- ADMIN_ERRORS END --- */}
|
|
364
|
+
{/* --- ADMIN_USERS START --- */}
|
|
365
|
+
<Link
|
|
366
|
+
href="/admin/users"
|
|
367
|
+
style={{
|
|
368
|
+
padding: '8px 16px',
|
|
369
|
+
background: activeTab === "users" ? 'var(--primary)' : 'transparent',
|
|
370
|
+
color: activeTab === "users" ? '#fff' : 'var(--text-muted)',
|
|
371
|
+
border: 'none',
|
|
372
|
+
borderRadius: 4,
|
|
373
|
+
cursor: 'pointer',
|
|
374
|
+
fontWeight: 'bold',
|
|
375
|
+
textDecoration: 'none'
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
User Management
|
|
379
|
+
</Link>
|
|
380
|
+
{/* --- ADMIN_USERS END --- */}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
129
383
|
<div className="controls">
|
|
130
|
-
|
|
384
|
+
{/* --- ANALYTICS START --- */}
|
|
385
|
+
<button onClick={() => handleMonthChange(-1)}>← Previous Month</button>
|
|
386
|
+
<strong>{month}</strong>
|
|
387
|
+
<button onClick={() => handleMonthChange(1)}>Next Month →</button>
|
|
388
|
+
{/* --- ANALYTICS END --- */}
|
|
389
|
+
{/* --- NO_ANALYTICS START --- */}
|
|
390
|
+
{/* --- ADMIN_ERRORS START --- */}
|
|
391
|
+
<button onClick={() => handleMonthChange(-1)}>← Previous Month</button>
|
|
131
392
|
<strong>{month}</strong>
|
|
132
|
-
<button onClick={() => handleMonthChange(1)}>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
393
|
+
<button onClick={() => handleMonthChange(1)}>Next Month →</button>
|
|
394
|
+
{/* --- ADMIN_ERRORS END --- */}
|
|
395
|
+
{/* --- NO_ANALYTICS END --- */}
|
|
396
|
+
|
|
397
|
+
{/* --- ADMIN_WITH_AUTH START --- */}
|
|
398
|
+
<button className="btn-logout" onClick={() => loginUser(null)}>Log Out</button>
|
|
399
|
+
{/* --- ADMIN_WITH_AUTH END --- */}
|
|
400
|
+
|
|
401
|
+
{/* --- ADMIN_NO_AUTH START --- */}
|
|
136
402
|
<button className="btn-logout" onClick={() => {
|
|
137
|
-
setLoggedIn(false);
|
|
138
|
-
setPassword("");
|
|
139
403
|
localStorage.removeItem("admin_pwd");
|
|
140
|
-
|
|
404
|
+
window.location.reload();
|
|
405
|
+
}}>Log Out</button>
|
|
406
|
+
{/* --- ADMIN_NO_AUTH END --- */}
|
|
141
407
|
</div>
|
|
142
408
|
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
409
|
+
{loading && activeTab === "analytics" && <p style={{ textAlign: "center" }}>Loading...</p>}
|
|
410
|
+
{error && activeTab === "analytics" && <p style={{ color: "#ef4444", textAlign: "center" }}>{error}</p>}
|
|
411
|
+
|
|
412
|
+
{/* --- ANALYTICS START --- */}
|
|
413
|
+
{activeTab === "analytics" && (
|
|
414
|
+
<AnalyticsTab records={records} month={month} />
|
|
415
|
+
)}
|
|
416
|
+
{/* --- ANALYTICS END --- */}
|
|
417
|
+
|
|
418
|
+
{/* --- ADMIN_ERRORS START --- */}
|
|
419
|
+
{activeTab === "errors" && (
|
|
420
|
+
<ErrorsTab errors={errorsList} loading={loadingErrors} errorMsg={errorLogsError} />
|
|
421
|
+
)}
|
|
422
|
+
{/* --- ADMIN_ERRORS END --- */}
|
|
423
|
+
|
|
424
|
+
{/* --- ADMIN_USERS START --- */}
|
|
425
|
+
{activeTab === "users" && (
|
|
426
|
+
<UsersTab users={usersList} loading={loadingUsers} errorMsg={usersError} onRoleChange={handleRoleChange} />
|
|
427
|
+
)}
|
|
428
|
+
{/* --- ADMIN_USERS END --- */}
|
|
158
429
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
430
|
+
{/* --- NO_ANALYTICS START --- */}
|
|
431
|
+
{/* --- NO_ADMIN_ERRORS START --- */}
|
|
432
|
+
{/* --- NO_ADMIN_USERS START --- */}
|
|
433
|
+
<div style={{ textAlign: 'center', padding: 40, border: '1px dashed var(--border)', borderRadius: 8 }}>
|
|
434
|
+
<h3>Generic Admin Panel</h3>
|
|
435
|
+
<p style={{ color: 'var(--text-muted)' }}>Welcome to the admin panel.</p>
|
|
164
436
|
</div>
|
|
437
|
+
{/* --- NO_ADMIN_USERS END --- */}
|
|
438
|
+
{/* --- NO_ADMIN_ERRORS END --- */}
|
|
439
|
+
{/* --- NO_ANALYTICS END --- */}
|
|
165
440
|
</div>
|
|
166
441
|
);
|
|
167
442
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useGroup, GroupTable, getGroupedCount } from "easy-analytics/react";
|
|
3
|
+
|
|
4
|
+
type AnalyticsTabProps = {
|
|
5
|
+
records: any[];
|
|
6
|
+
month: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function AnalyticsTab({ records, month }: AnalyticsTabProps) {
|
|
10
|
+
const byURL = useGroup(records, (r: any) => {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(r.url).pathname;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return r.url || "Unknown";
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const byReferrer = useGroup(records, (r: any) => r.referrer || "Direct", (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
19
|
+
|
|
20
|
+
const byWidth = useGroup(records, (r: any) => {
|
|
21
|
+
const w = r.window?.innerWidth || 0;
|
|
22
|
+
if (w >= 1920) return ">= 1920px Full HD";
|
|
23
|
+
if (w >= 1280) return "1280px - 1920px HD";
|
|
24
|
+
if (w >= 768) return "768px - 1280px Tablet";
|
|
25
|
+
if (w > 0) return "< 768px Mobile";
|
|
26
|
+
return "Unknown";
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const byBrowserLike = useGroup(records, (r: any) => {
|
|
30
|
+
const ua = (r.userAgent || "").toLowerCase();
|
|
31
|
+
if (ua.includes("firefox")) return "Firefox";
|
|
32
|
+
if (ua.includes("edg")) return "Edge";
|
|
33
|
+
if (ua.includes("chrome")) return "Chrome";
|
|
34
|
+
if (ua.includes("safari")) return "Safari";
|
|
35
|
+
return "Other/Unknown";
|
|
36
|
+
}, (rs: any) => getGroupedCount(rs, (row: any) => row.localId));
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<div className="chart-container">
|
|
41
|
+
<h3>Visits per day</h3>
|
|
42
|
+
<div className="chart">
|
|
43
|
+
{Array.from({ length: new Date(month.slice(0, 4) as any, month.slice(5, 7) as any, 0).getDate() }, (_, i) => {
|
|
44
|
+
const dayStr = String(i + 1).padStart(2, "0");
|
|
45
|
+
const fullDate = `${month}-${dayStr}`;
|
|
46
|
+
const count = records.filter((r: any) => r.created && r.created.slice(0, 10) === fullDate).length;
|
|
47
|
+
const maxCount = Math.max(...Array.from({ length: 31 }, (_, dayIndex) => {
|
|
48
|
+
const dStr = String(dayIndex + 1).padStart(2, "0");
|
|
49
|
+
return records.filter((r: any) => r.created && r.created.slice(0, 10) === `${month}-${dStr}`).length;
|
|
50
|
+
}));
|
|
51
|
+
const heightPercent = maxCount > 0 ? (count / maxCount) * 80 : 0;
|
|
52
|
+
return (
|
|
53
|
+
<div key={i} className="bar-wrapper">
|
|
54
|
+
<span className="count">{count || ""}</span>
|
|
55
|
+
<div className="bar active" style={{ height: `${heightPercent}%` }}></div>
|
|
56
|
+
<span className="date">{i + 1}</span>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="grid">
|
|
64
|
+
<GroupTable title="Most visited paths" data={byURL} columnName="Path" />
|
|
65
|
+
<GroupTable title="Referrers" data={byReferrer} columnName="Source" />
|
|
66
|
+
<GroupTable title="Browsers" data={byBrowserLike} columnName="Browser" />
|
|
67
|
+
<GroupTable title="Display resolutions" data={byWidth} columnName="Resolution" />
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type ErrorsTabProps = {
|
|
4
|
+
errors: any[];
|
|
5
|
+
loading: boolean;
|
|
6
|
+
errorMsg: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function ErrorsTab({ errors, loading, errorMsg }: ErrorsTabProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div>
|
|
12
|
+
<h3 style={{ marginBottom: 15 }}>Logged Errors</h3>
|
|
13
|
+
{loading && <p>Loading error logs...</p>}
|
|
14
|
+
{errorMsg && <p style={{ color: "#ef4444" }}>{errorMsg}</p>}
|
|
15
|
+
{!loading && !errorMsg && errors.length === 0 && (
|
|
16
|
+
<p style={{ color: "var(--text-muted)" }}>No errors logged for this month.</p>
|
|
17
|
+
)}
|
|
18
|
+
{!loading && !errorMsg && errors.length > 0 && (
|
|
19
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
|
|
20
|
+
{errors.map((errRecord: any, i: number) => (
|
|
21
|
+
<div key={i} style={{ padding: 15, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 6 }}>
|
|
22
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10, fontSize: '0.9em' }}>
|
|
23
|
+
<span style={{ color: 'var(--primary)', fontWeight: 'bold' }}>{errRecord.name || "Error"}</span>
|
|
24
|
+
<span style={{ color: 'var(--text-muted)' }}>{errRecord.created ? new Date(errRecord.created).toLocaleString() : ""}</span>
|
|
25
|
+
</div>
|
|
26
|
+
<p style={{ margin: '5px 0', fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: '#f87171' }}>
|
|
27
|
+
{errRecord.message}
|
|
28
|
+
</p>
|
|
29
|
+
{errRecord.stack && (
|
|
30
|
+
<details style={{ marginTop: 10 }}>
|
|
31
|
+
<summary style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.85em' }}>Show Stack Trace</summary>
|
|
32
|
+
<pre style={{ marginTop: 5, padding: 10, background: '#09090b', overflowX: 'auto', fontSize: '0.8em', color: 'var(--text-muted)' }}>
|
|
33
|
+
{errRecord.stack}
|
|
34
|
+
</pre>
|
|
35
|
+
</details>
|
|
36
|
+
)}
|
|
37
|
+
<div style={{ marginTop: 10, fontSize: '0.8em', color: 'var(--text-muted)' }}>
|
|
38
|
+
URL: {errRecord.url} | User Agent: {errRecord.userAgent}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|