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:
riyadhafraa
2026-04-21 08:50:25 +00:00
parent 7b77de107f
commit 13040c8a93
4 changed files with 122 additions and 57 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+3 -1
View File
@@ -305,7 +305,9 @@
"mostActiveUsers": "أكثر المستخدمين نشاطًا",
"mostActiveUsersSubtitle": "حسب فتح التطبيقات في {{range}}",
"leaderboardEmpty": "لا يوجد نشاط بعد",
"openCount": "{{count}} فتحة"
"openCount": "{{count}} فتحة",
"viewAppDetails": "عرض تفاصيل {{name}}",
"viewUserDetails": "عرض تفاصيل {{name}}"
}
},
"notFound": {
+3 -1
View File
@@ -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": {
+116 -55
View File
@@ -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>
);
})}