Task #32: Let admins click a top app or active user to see details
Made the leaderboard rows on the admin dashboard interactive so admins can
drill in directly to investigate.
Changes:
- artifacts/teaboy-os/src/pages/admin.tsx
- DashboardSection: each "Top apps" and "Most active users" row is now a
real <button type="button"> with a descriptive aria-label
("View details for ..."), focus-visible ring, and hover styles.
- New onSelectApp / onSelectUser props passed from AdminPage:
* Top apps row -> switches to the Apps section AND opens the existing
Edit App modal prefilled with that app's data (uses the apps list
already loaded via useListApps to look up the full record).
* Most active users row -> switches to the Users section, scrolls the
matching user row into view, and applies a temporary primary-colored
ring highlight that fades after ~2.5s.
- Added userRowRefs (Map of id -> div) and a highlightedUserId state +
effect to drive the scroll/highlight behavior.
- artifacts/teaboy-os/src/locales/{en,ar}.json
- Added admin.dashboard.viewAppDetails / viewUserDetails strings used as
aria-labels.
Verification: e2e tested via the testing skill — login as admin, click a
top app row (modal opens), Cancel, click a most-active-users row (users
section opens with highlighted row), and Tab+Enter on a top apps row also
opens the modal (keyboard accessibility confirmed).
Notes / deviations: pre-existing TypeScript errors in the project's API
client types (topApps/mostActiveUsers/rangeFrom not yet on AdminStats,
and unrelated chat.tsx/home.tsx errors) are not introduced by this task
and were not addressed.
Replit-Task-Id: 95c90dfe-6761-4d04-8e71-f2aa14e590bb
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -305,7 +305,9 @@
|
||||
"mostActiveUsers": "أكثر المستخدمين نشاطًا",
|
||||
"mostActiveUsersSubtitle": "حسب فتح التطبيقات في {{range}}",
|
||||
"leaderboardEmpty": "لا يوجد نشاط بعد",
|
||||
"openCount": "{{count}} فتحة"
|
||||
"openCount": "{{count}} فتحة",
|
||||
"viewAppDetails": "عرض تفاصيل {{name}}",
|
||||
"viewUserDetails": "عرض تفاصيل {{name}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -302,7 +302,9 @@
|
||||
"mostActiveUsers": "Most active users",
|
||||
"mostActiveUsersSubtitle": "By app opens in {{range}}",
|
||||
"leaderboardEmpty": "No activity yet",
|
||||
"openCount": "{{count}} opens"
|
||||
"openCount": "{{count}} opens",
|
||||
"viewAppDetails": "View details for {{name}}",
|
||||
"viewUserDetails": "View details for {{name}}"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
@@ -143,6 +143,42 @@ export default function AdminPage() {
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
const [section, setSection] = useState<AdminSection>("dashboard");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [highlightedUserId, setHighlightedUserId] = useState<number | null>(null);
|
||||
const userRowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "users" || highlightedUserId == null) return;
|
||||
const node = userRowRefs.current.get(highlightedUserId);
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
const timer = window.setTimeout(() => setHighlightedUserId(null), 2500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [section, highlightedUserId, users]);
|
||||
|
||||
const handleSelectAppFromDashboard = (appId: number) => {
|
||||
const app = apps?.find((a) => a.id === appId);
|
||||
setSection("apps");
|
||||
if (app) {
|
||||
setEditingApp({
|
||||
id: app.id,
|
||||
form: {
|
||||
nameAr: app.nameAr ?? "",
|
||||
nameEn: app.nameEn ?? "",
|
||||
slug: app.slug ?? "",
|
||||
iconName: app.iconName ?? "Grid2X2",
|
||||
route: app.route ?? "/",
|
||||
color: app.color ?? "#6366f1",
|
||||
sortOrder: app.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectUserFromDashboard = (userId: number) => {
|
||||
setSection("users");
|
||||
setHighlightedUserId(userId);
|
||||
};
|
||||
|
||||
const navItems: { key: AdminSection; label: string; Icon: typeof LayoutDashboard }[] = [
|
||||
{ key: "dashboard", label: t("admin.nav.dashboard"), Icon: LayoutDashboard },
|
||||
@@ -441,6 +477,8 @@ export default function AdminPage() {
|
||||
onCustomFromChange={setCustomFrom}
|
||||
onCustomToChange={setCustomTo}
|
||||
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
|
||||
onSelectApp={handleSelectAppFromDashboard}
|
||||
onSelectUser={handleSelectUserFromDashboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -585,7 +623,17 @@ export default function AdminPage() {
|
||||
{t("admin.addUser")}
|
||||
</Button>
|
||||
{users?.map((u) => (
|
||||
<div key={u.id} className="glass-panel rounded-2xl p-4 flex items-center justify-between gap-3">
|
||||
<div
|
||||
key={u.id}
|
||||
ref={(node) => {
|
||||
if (node) userRowRefs.current.set(u.id, node);
|
||||
else userRowRefs.current.delete(u.id);
|
||||
}}
|
||||
className={cn(
|
||||
"glass-panel rounded-2xl p-4 flex items-center justify-between gap-3 transition-all",
|
||||
highlightedUserId === u.id && "ring-2 ring-primary shadow-lg shadow-primary/20",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm text-foreground">{u.username}</div>
|
||||
<div className="text-xs text-muted-foreground">{u.email}</div>
|
||||
@@ -893,6 +941,8 @@ function DashboardSection({
|
||||
onCustomFromChange,
|
||||
onCustomToChange,
|
||||
onApplyCustom,
|
||||
onSelectApp,
|
||||
onSelectUser,
|
||||
}: {
|
||||
apps: App[] | undefined;
|
||||
services: Service[] | undefined;
|
||||
@@ -907,6 +957,8 @@ function DashboardSection({
|
||||
onCustomFromChange: (v: string) => void;
|
||||
onCustomToChange: (v: string) => void;
|
||||
onApplyCustom: () => void;
|
||||
onSelectApp: (appId: number) => void;
|
||||
onSelectUser: (userId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1241,34 +1293,39 @@ function DashboardSection({
|
||||
<ol className="space-y-2">
|
||||
{topApps.map((app, idx) => {
|
||||
const widthPct = (app.count / maxTopAppCount) * 100;
|
||||
const appName = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
return (
|
||||
<li
|
||||
key={app.appId}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${app.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{lang === "ar" ? app.nameAr : app.nameEn}
|
||||
<li key={app.appId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectApp(app.appId)}
|
||||
aria-label={t("admin.dashboard.viewAppDetails", { name: appName })}
|
||||
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg shrink-0"
|
||||
style={{ backgroundColor: `${app.color}40` }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{appName}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{app.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{app.count}
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-sky-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-sky-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -1297,39 +1354,43 @@ function DashboardSection({
|
||||
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
|
||||
const initial = (displayName || u.username || "?").trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<li
|
||||
key={u.userId}
|
||||
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full shrink-0 bg-emerald-100/70 text-emerald-700 flex items-center justify-center text-xs font-semibold">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
{displayName !== u.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
@{u.username}
|
||||
<li key={u.userId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectUser(u.userId)}
|
||||
aria-label={t("admin.dashboard.viewUserDetails", { name: displayName })}
|
||||
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
|
||||
>
|
||||
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full shrink-0 bg-emerald-100/70 text-emerald-700 flex items-center justify-center text-xs font-semibold">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
{displayName !== u.username && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
@{u.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{u.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
|
||||
{u.count}
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-300 to-emerald-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-300 to-emerald-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user