added avatar and login mail to user list in admin panel
This commit is contained in:
parent
f358d5a64a
commit
24276d1379
14 changed files with 301 additions and 10 deletions
78
cmd/telesrv-admin/web/src/components/Avatar.tsx
Normal file
78
cmd/telesrv-admin/web/src/components/Avatar.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
// Mirrors internal/web/server.go's publicAvatarGradients + initials() exactly,
|
||||
// so admin-console avatars look identical to the public preview cards.
|
||||
const AVATAR_GRADIENTS: [string, string][] = [
|
||||
["#FF885E", "#FF516A"],
|
||||
["#FFCD6A", "#FFA85C"],
|
||||
["#82B1FF", "#665FFF"],
|
||||
["#A0DE7E", "#54CB68"],
|
||||
["#53EDD6", "#28C9B7"],
|
||||
["#72D5FD", "#2A9EF1"],
|
||||
["#E0A2F3", "#D669ED"]
|
||||
];
|
||||
|
||||
function avatarGradient(id: number): [string, string] {
|
||||
const n = Math.abs(id) % AVATAR_GRADIENTS.length;
|
||||
return AVATAR_GRADIENTS[n];
|
||||
}
|
||||
|
||||
function firstCodePoint(word: string): string {
|
||||
const chars = Array.from(word);
|
||||
return chars.length > 0 ? chars[0] : "";
|
||||
}
|
||||
|
||||
function avatarInitials(firstName: string, lastName: string, username: string): string {
|
||||
const title = `${firstName} ${lastName}`.trim();
|
||||
const words = title.split(/\s+/).filter(Boolean);
|
||||
const source = words.length > 0 ? words : (username ? [username] : []);
|
||||
if (source.length === 0) return "T";
|
||||
let out = firstCodePoint(source[0]);
|
||||
if (source.length > 1) {
|
||||
out += firstCodePoint(source[source.length - 1]);
|
||||
}
|
||||
return out.toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
userID,
|
||||
firstName,
|
||||
lastName,
|
||||
username = "",
|
||||
size = 34
|
||||
}: {
|
||||
userID: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [userID]);
|
||||
|
||||
if (failed) {
|
||||
const [from, to] = avatarGradient(userID);
|
||||
return (
|
||||
<div
|
||||
className="avatar-fallback"
|
||||
style={{ width: size, height: size, background: `linear-gradient(135deg, ${from}, ${to})`, fontSize: Math.round(size * 0.42) }}
|
||||
>
|
||||
{avatarInitials(firstName, lastName, username)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={`/api/accounts/${userID}/avatar`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: size, height: size }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -106,6 +106,7 @@ const translations: Record<string, string> = {
|
|||
"account.searchPlaceholder": "User ID / phone / username",
|
||||
"account.userID": "User ID",
|
||||
"account.phone": "Phone",
|
||||
"account.loginEmail": "Login email",
|
||||
"account.lastActive": "Last active",
|
||||
"account.notVerified": "Not verified",
|
||||
"account.notPremium": "Not premium",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
|
|
@ -88,10 +89,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="avatar-col"></th>
|
||||
<th>{t("account.userID")}</th>
|
||||
<th>{t("account.phone")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.name")}</th>
|
||||
<th>{t("account.loginEmail")}</th>
|
||||
<th>{t("common.device")}</th>
|
||||
<th>{t("account.lastActive")}</th>
|
||||
<th>{t("account.premium")}</th>
|
||||
|
|
@ -104,10 +107,12 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<tbody>
|
||||
{data?.rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{displayPhone(row.Phone)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td>{displayName(row)}</td>
|
||||
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td>
|
||||
<td>{row.DeviceCount}</td>
|
||||
<td>{formatDate(row.LastActiveAt)}</td>
|
||||
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
|
||||
|
|
@ -117,7 +122,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -413,6 +413,22 @@
|
|||
.gift-select-col { width: 34px; text-align: center; }
|
||||
.gift-select-col input { width: 15px; height: 15px; }
|
||||
|
||||
.avatar-col { width: 44px; }
|
||||
.muted-cell { color: var(--muted); }
|
||||
.avatar-photo-img,
|
||||
.avatar-fallback {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.gift-bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type AccountRow = {
|
|||
PremiumUntil: number;
|
||||
LastActiveAt: string;
|
||||
DeviceCount: number;
|
||||
LoginEmail: string;
|
||||
};
|
||||
|
||||
export type RestrictionRow = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue