feat(fe): add Member Intelligence (/users) page wired to full reputation data

- New /users route: member roster (trust score, clean/flagged counts, message volume)
  + inspector detail (trust breakdown, clean streak, infractions, AI profile,
  recent messages) with live search
- Backend dashboard.listUsers/getUserDetail now JOIN user_reputations to expose
  trust_score, clean_message_streak, total_infractions, last_infraction_at +
  warn_count/clean_count breakdowns
- Frontend types updated to match; useUsers refactored to PaginatedUsers shape,
  added useUserSearch for the old search behavior
- Nav rail + mobile nav: add Users entry
This commit is contained in:
asepharyana
2026-09-02 00:01:12 +07:00
parent 7c21629404
commit 30d640ca5b
8 changed files with 456 additions and 3 deletions
@@ -5,6 +5,7 @@ import {
pgChannelCulturesTable,
pgMessagesTable,
pgUserProfilesTable,
pgUserReputationsTable,
pgVoiceRecordingsTable,
} from "../../shared/index.js";
import type { ListUsersQuery } from "./dashboard.service.js";
@@ -153,8 +154,14 @@ export class DashboardRepository {
m.username,
m.avatar_url,
p.profile_summary,
r.trust_score,
r.clean_message_streak,
r.total_infractions,
r.last_infraction_at,
m.total_messages,
m.flagged_count,
m.clean_count,
m.warn_count,
m.last_message_at
FROM (
SELECT
@@ -163,11 +170,14 @@ export class DashboardRepository {
avatar_url,
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count,
MAX(created_at) AS last_message_at
FROM ${pgMessagesTable}
GROUP BY user_id, username, avatar_url
) m
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
${whereClause}
ORDER BY m.last_message_at DESC NULLS LAST
LIMIT ${limit + 1}
@@ -180,8 +190,20 @@ export class DashboardRepository {
username: r.username as string | null,
avatar_url: r.avatar_url as string | null,
profile_summary: r.profile_summary as string | null,
trust_score: r.trust_score != null ? Number(r.trust_score) : null,
clean_message_streak:
r.clean_message_streak != null
? Number(r.clean_message_streak)
: null,
total_infractions:
r.total_infractions != null ? Number(r.total_infractions) : null,
last_infraction_at: r.last_infraction_at
? Number(r.last_infraction_at)
: null,
total_messages: Number(r.total_messages),
flagged_count: Number(r.flagged_count),
clean_count: Number(r.clean_count),
warn_count: Number(r.warn_count),
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
}));
@@ -429,8 +451,13 @@ export class DashboardRepository {
m.total_messages,
m.flagged_count,
m.clean_count,
m.warn_count,
p.profile_summary,
p.last_analyzed_at
p.last_analyzed_at,
r.trust_score,
r.clean_message_streak,
r.total_infractions,
r.last_infraction_at
FROM (
SELECT
user_id,
@@ -438,12 +465,14 @@ export class DashboardRepository {
avatar_url,
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count
FROM ${pgMessagesTable}
WHERE user_id = ${userId}
GROUP BY user_id, username, avatar_url
) m
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
`);
const row = userResult.rows[0] as Record<string, unknown> | undefined;
@@ -466,10 +495,21 @@ export class DashboardRepository {
total_messages: Number(row.total_messages),
flagged_count: Number(row.flagged_count),
clean_count: Number(row.clean_count),
warn_count: Number(row.warn_count),
profile_summary: row.profile_summary as string | null,
last_analyzed_at: row.last_analyzed_at
? Number(row.last_analyzed_at)
: null,
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
clean_message_streak:
row.clean_message_streak != null
? Number(row.clean_message_streak)
: null,
total_infractions:
row.total_infractions != null ? Number(row.total_infractions) : null,
last_infraction_at: row.last_infraction_at
? Number(row.last_infraction_at)
: null,
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
id: String(r.id),
content: String(r.content),
@@ -0,0 +1,14 @@
import { getUsers } from "@/lib/api/server";
import { UsersView } from "./view";
export const dynamic = "force-dynamic";
export default async function UsersPage() {
let users: import("@/lib/types").PaginatedUsers | undefined;
try {
users = await getUsers(50);
} catch {
/* client hooks surface errors */
}
return <UsersView initialUsers={users} />;
}
@@ -0,0 +1,372 @@
"use client";
import {
AlertTriangle,
CheckCircle2,
Clock,
Hash,
Search,
Shield,
ShieldAlert,
Sparkles,
Trophy,
Users as UsersIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
Badge,
GlassPanel,
Input,
Skeleton,
} from "@/components/primitives";
import {
EmptyState,
ErrorState,
PageTransition,
SectionHeader,
SkeletonRows,
} from "@/components/shared";
import { useUserDetail, useUsers } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type {
DashboardUser,
DashboardUserDetail,
PaginatedUsers,
} from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
function trustTone(score?: number | null) {
if (score == null) return "neutral";
if (score >= 70) return "signal";
if (score >= 45) return "amber";
return "vermilion";
}
function trustLabel(score?: number | null) {
if (score == null) return "UNKNOWN";
if (score >= 80) return "TRUSTED";
if (score >= 60) return "LOW RISK";
if (score >= 45) return "MONITOR";
if (score >= 30) return "WATCH";
return "HIGH RISK";
}
export function UsersView({ initialUsers }: { initialUsers?: PaginatedUsers }) {
const { data: usersData, isLoading, error } = useUsers(initialUsers);
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const detail = useUserDetail(selectedId);
const ambient = useAmbient();
const users = usersData?.data;
useEffect(() => {
ambient.set("signal", 0.25, "members");
}, [ambient]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return users ?? [];
return (users ?? []).filter(
(u) =>
u.username?.toLowerCase().includes(q) ||
u.user_id.toLowerCase().includes(q),
);
}, [users, search]);
const containerRef = useStaggerReveal<HTMLDivElement>(".user-tile", {
stagger: 0.03,
y: 8,
dependencies: [users?.length],
});
if (error && !users) return <ErrorState error={error} onRetry={() => {}} />;
if (isLoading && !users) return <SkeletonRows rows={10} />;
return (
<PageTransition>
<div ref={containerRef} className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="user-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Member Intelligence · Persona Registry
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>NODES:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
{filtered.length} REGISTERED
</span>
</div>
</div>
{/* Search Bar */}
<div className="user-tile relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search members by name or ID..."
className="pl-9 text-xs"
/>
</div>
<div className="grid gap-3 lg:grid-cols-5">
{/* Member List */}
<GlassPanel className="user-tile lg:col-span-3">
<SectionHeader
eyebrow="registry"
title="Member Roster"
action={
<span className="mono text-xs text-[#8a8f98]">
{filtered.length} members
</span>
}
/>
{filtered.length === 0 ? (
<EmptyState
icon={<UsersIcon className="size-7" />}
title="No members found"
description="Members appear once messages are captured."
/>
) : (
<div className="mt-3 max-h-[65vh] space-y-1.5 overflow-y-auto pr-1">
{filtered.map((u) => (
<UserRow
key={u.user_id}
user={u}
selected={selectedId === u.user_id}
onSelect={() =>
setSelectedId((prev) =>
prev === u.user_id ? null : u.user_id,
)
}
/>
))}
</div>
)}
</GlassPanel>
{/* Detail Panel */}
<GlassPanel className="user-tile lg:col-span-2">
<SectionHeader eyebrow="persona" title="Member Inspector" />
{!selectedId ? (
<EmptyState
title="Select a member"
description="Click any member to see trust profile, activity, and recent messages."
/>
) : detail.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16" />
<Skeleton className="h-24" />
<Skeleton className="h-32" />
</div>
) : detail.data ? (
<MemberDetail user={detail.data} />
) : (
<EmptyState title="Member not found" />
)}
</GlassPanel>
</div>
</div>
</PageTransition>
);
}
function UserRow({
user,
selected,
onSelect,
}: {
user: DashboardUser;
selected: boolean;
onSelect: () => void;
}) {
const flagPct =
user.total_messages > 0
? (user.flagged_count / user.total_messages) * 100
: 0;
const tone = trustTone(user.trust_score);
return (
<button
type="button"
onClick={onSelect}
className={`flex w-full items-center gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
selected
? "border-signal/50 bg-signal/10 shadow-xs"
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
}`}
>
<Avatar src={user.avatar_url} name={user.username ?? "?"} size={36} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-semibold text-ink">
{user.username ?? "unknown"}
</span>
{user.trust_score != null && (
<Badge tone={tone} className="font-mono text-[9px]">
{Math.round(user.trust_score)} TRUST
</Badge>
)}
</div>
<div className="mt-0.5 flex items-center gap-2 font-mono text-[9px] text-ink-faint">
<span className="truncate">#{user.user_id.slice(0, 10)}</span>
<span>·</span>
<span className="text-ink-muted">
{formatNumber(user.total_messages)} msgs
</span>
{user.flagged_count > 0 && (
<span className="text-vermilion">
{formatNumber(user.flagged_count)} flagged
</span>
)}
</div>
</div>
{flagPct > 5 && (
<span className="shrink-0 rounded bg-vermilion/10 px-1.5 py-0.5 font-mono text-[9px] text-vermilion">
{flagPct.toFixed(0)}%
</span>
)}
</button>
);
}
function MemberDetail({ user }: { user: DashboardUserDetail }) {
const flagPct =
user.total_messages > 0
? (user.flagged_count / user.total_messages) * 100
: 0;
const cleanPct =
user.total_messages > 0
? (user.clean_count / user.total_messages) * 100
: 0;
return (
<div className="space-y-3 text-sm">
{/* Identity */}
<div className="flex items-center gap-3">
<Avatar src={user.avatar_url} name={user.username ?? "?"} size={44} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-ink">
{user.username ?? "unknown"}
</div>
<div className="truncate font-mono text-[10px] text-ink-faint">
#{user.user_id}
</div>
</div>
<Badge
tone={trustTone(user.trust_score)}
className="font-mono text-[9px]"
>
{trustLabel(user.trust_score)}
</Badge>
</div>
{/* Trust Metrics */}
<div className="grid grid-cols-3 gap-2">
<div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Trust</div>
<div className="font-mono text-sm font-semibold text-signal">
{user.trust_score ?? "—"}
</div>
</div>
<div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Clean Streak</div>
<div className="font-mono text-sm font-semibold text-success">
{user.clean_message_streak ?? 0}
</div>
</div>
<div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Infractions</div>
<div className="font-mono text-sm font-semibold text-vermilion">
{user.total_infractions ?? 0}
</div>
</div>
</div>
{/* Activity Breakdown */}
<div className="space-y-1.5">
{[
{
label: "Clean",
count: user.clean_count,
pct: cleanPct,
tone: "bg-success",
},
{
label: "Flagged",
count: user.flagged_count,
pct: flagPct,
tone: "bg-vermilion",
},
].map((row) => (
<div key={row.label} className="flex items-center gap-2 text-[10px]">
<span className="w-14 shrink-0 font-mono text-ink-muted">
{row.label}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full ${row.tone}`}
style={{ width: `${Math.min(100, row.pct)}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-ink-faint">
{formatNumber(row.count)}
</span>
</div>
))}
<div className="flex items-center justify-between pt-1 font-mono text-[9px] text-ink-faint">
<span>Total: {formatNumber(user.total_messages)} messages</span>
{user.last_message_at && (
<span suppressHydrationWarning>
last {formatRelativeTime(user.last_message_at)}
</span>
)}
</div>
</div>
{/* Profile summary */}
{user.profile_summary && (
<div>
<div className="eyebrow mb-1 flex items-center gap-1">
<Sparkles className="size-3 text-signal" /> AI profile
</div>
<div className="hud-card line-clamp-4 p-2.5 text-[11px] text-ink-muted leading-relaxed">
{user.profile_summary}
</div>
</div>
)}
{/* Recent messages */}
{user.recent_messages.length > 0 && (
<div>
<div className="eyebrow mb-1.5">Recent Messages</div>
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
{user.recent_messages.slice(0, 8).map((m) => (
<div
key={m.id}
className="rounded-[6px] border border-hairline bg-surface-2/60 p-2 text-[11px]"
>
<div className="flex items-center gap-2 font-mono text-[9px] text-ink-faint">
<Hash className="size-2.5 text-signal" />
<span className="truncate">#{m.channel_id.slice(0, 12)}</span>
<span className="ml-auto" suppressHydrationWarning>
{formatRelativeTime(m.created_at)}
</span>
</div>
<div className="mt-1 line-clamp-2 text-ink-soft">
{m.content || "(attachment/embed)"}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ export {
useTopReactions,
useTopReactors,
useUserDetail,
useUserSearch,
useUsers,
} from "./use-dashboard";
export { useGuilds } from "./use-guilds";
+10 -1
View File
@@ -6,6 +6,7 @@ import type {
DashboardChannelDetail,
DashboardStats,
DashboardUserDetail,
PaginatedUsers,
TopReactedMessage,
TopReactor,
} from "@/lib/types";
@@ -33,7 +34,15 @@ export function useActivity(days = 14, initialData?: DashboardActivity) {
);
}
export function useUsers(search?: string) {
export function useUsers(initialData?: PaginatedUsers) {
return useSWR<PaginatedUsers>(
["dashboard-users"],
() => dashboardApi.listUsers(50),
{ fallbackData: initialData },
);
}
export function useUserSearch(search?: string) {
return useSWR(
["dashboard-users", search ?? ""],
async () => {
+5
View File
@@ -71,6 +71,11 @@ export async function getActivity(days = 14): Promise<DashboardActivity> {
days,
}) as unknown as Promise<DashboardActivity>;
}
export async function getUsers(limit = 20) {
return serverOrpc().dashboard.users({
limit,
}) as unknown as Promise<import("@/lib/types").PaginatedUsers>;
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
+7
View File
@@ -7,6 +7,7 @@ import {
Music,
Search,
Shield,
Users,
} from "lucide-react";
export interface NavItem {
@@ -33,6 +34,12 @@ export const navItems: NavItem[] = [
icon: MessageSquare,
matchPrefix: "/messages",
},
{
href: "/users",
label: "Users",
icon: Users,
matchPrefix: "/users",
},
{
href: "/voice",
label: "Voice",
@@ -54,9 +54,13 @@ export interface DashboardUser {
profile_summary?: string | null;
total_messages: number;
flagged_count: number;
clean_count?: number;
warn_count?: number;
last_message_at?: number | null;
trust_score?: number | null;
clean_message_streak?: number | null;
total_infractions?: number | null;
last_infraction_at?: number | null;
}
export interface DashboardUserDetail extends DashboardUser {
@@ -64,6 +68,7 @@ export interface DashboardUserDetail extends DashboardUser {
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number;
warn_count?: number;
recent_messages: MessageRecord[];
}