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:
@@ -5,6 +5,7 @@ import {
|
|||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
pgUserProfilesTable,
|
pgUserProfilesTable,
|
||||||
|
pgUserReputationsTable,
|
||||||
pgVoiceRecordingsTable,
|
pgVoiceRecordingsTable,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||||
@@ -153,8 +154,14 @@ export class DashboardRepository {
|
|||||||
m.username,
|
m.username,
|
||||||
m.avatar_url,
|
m.avatar_url,
|
||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
|
r.trust_score,
|
||||||
|
r.clean_message_streak,
|
||||||
|
r.total_infractions,
|
||||||
|
r.last_infraction_at,
|
||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
|
m.clean_count,
|
||||||
|
m.warn_count,
|
||||||
m.last_message_at
|
m.last_message_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -163,11 +170,14 @@ export class DashboardRepository {
|
|||||||
avatar_url,
|
avatar_url,
|
||||||
COUNT(*)::int AS total_messages,
|
COUNT(*)::int AS total_messages,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
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
|
MAX(created_at) AS last_message_at
|
||||||
FROM ${pgMessagesTable}
|
FROM ${pgMessagesTable}
|
||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
|
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY m.last_message_at DESC NULLS LAST
|
ORDER BY m.last_message_at DESC NULLS LAST
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
@@ -180,8 +190,20 @@ export class DashboardRepository {
|
|||||||
username: r.username as string | null,
|
username: r.username as string | null,
|
||||||
avatar_url: r.avatar_url as string | null,
|
avatar_url: r.avatar_url as string | null,
|
||||||
profile_summary: r.profile_summary 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),
|
total_messages: Number(r.total_messages),
|
||||||
flagged_count: Number(r.flagged_count),
|
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,
|
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -429,8 +451,13 @@ export class DashboardRepository {
|
|||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.clean_count,
|
m.clean_count,
|
||||||
|
m.warn_count,
|
||||||
p.profile_summary,
|
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 (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -438,12 +465,14 @@ export class DashboardRepository {
|
|||||||
avatar_url,
|
avatar_url,
|
||||||
COUNT(*)::int AS total_messages,
|
COUNT(*)::int AS total_messages,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
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}
|
FROM ${pgMessagesTable}
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
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;
|
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||||
@@ -466,10 +495,21 @@ export class DashboardRepository {
|
|||||||
total_messages: Number(row.total_messages),
|
total_messages: Number(row.total_messages),
|
||||||
flagged_count: Number(row.flagged_count),
|
flagged_count: Number(row.flagged_count),
|
||||||
clean_count: Number(row.clean_count),
|
clean_count: Number(row.clean_count),
|
||||||
|
warn_count: Number(row.warn_count),
|
||||||
profile_summary: row.profile_summary as string | null,
|
profile_summary: row.profile_summary as string | null,
|
||||||
last_analyzed_at: row.last_analyzed_at
|
last_analyzed_at: row.last_analyzed_at
|
||||||
? Number(row.last_analyzed_at)
|
? Number(row.last_analyzed_at)
|
||||||
: null,
|
: 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) => ({
|
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
content: String(r.content),
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
useTopReactions,
|
useTopReactions,
|
||||||
useTopReactors,
|
useTopReactors,
|
||||||
useUserDetail,
|
useUserDetail,
|
||||||
|
useUserSearch,
|
||||||
useUsers,
|
useUsers,
|
||||||
} from "./use-dashboard";
|
} from "./use-dashboard";
|
||||||
export { useGuilds } from "./use-guilds";
|
export { useGuilds } from "./use-guilds";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
DashboardChannelDetail,
|
DashboardChannelDetail,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardUserDetail,
|
DashboardUserDetail,
|
||||||
|
PaginatedUsers,
|
||||||
TopReactedMessage,
|
TopReactedMessage,
|
||||||
TopReactor,
|
TopReactor,
|
||||||
} from "@/lib/types";
|
} 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(
|
return useSWR(
|
||||||
["dashboard-users", search ?? ""],
|
["dashboard-users", search ?? ""],
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export async function getActivity(days = 14): Promise<DashboardActivity> {
|
|||||||
days,
|
days,
|
||||||
}) as unknown as Promise<DashboardActivity>;
|
}) 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 ----
|
// ---- Media ----
|
||||||
export async function getMediaStatus(): Promise<MediaState> {
|
export async function getMediaStatus(): Promise<MediaState> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Music,
|
Music,
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -33,6 +34,12 @@ export const navItems: NavItem[] = [
|
|||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
matchPrefix: "/messages",
|
matchPrefix: "/messages",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/users",
|
||||||
|
label: "Users",
|
||||||
|
icon: Users,
|
||||||
|
matchPrefix: "/users",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/voice",
|
href: "/voice",
|
||||||
label: "Voice",
|
label: "Voice",
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ export interface DashboardUser {
|
|||||||
profile_summary?: string | null;
|
profile_summary?: string | null;
|
||||||
total_messages: number;
|
total_messages: number;
|
||||||
flagged_count: number;
|
flagged_count: number;
|
||||||
|
clean_count?: number;
|
||||||
|
warn_count?: number;
|
||||||
last_message_at?: number | null;
|
last_message_at?: number | null;
|
||||||
trust_score?: number | null;
|
trust_score?: number | null;
|
||||||
clean_message_streak?: number | null;
|
clean_message_streak?: number | null;
|
||||||
|
total_infractions?: number | null;
|
||||||
|
last_infraction_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardUserDetail extends DashboardUser {
|
export interface DashboardUserDetail extends DashboardUser {
|
||||||
@@ -64,6 +68,7 @@ export interface DashboardUserDetail extends DashboardUser {
|
|||||||
clean_message_streak: number | null;
|
clean_message_streak: number | null;
|
||||||
total_infractions: number | null;
|
total_infractions: number | null;
|
||||||
clean_count: number;
|
clean_count: number;
|
||||||
|
warn_count?: number;
|
||||||
recent_messages: MessageRecord[];
|
recent_messages: MessageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user