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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>OwpenGram Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-CRrNXtUN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C7HtHaDP.css">
|
||||
<script type="module" crossorigin src="/assets/index-CGbIqVNE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BAJpg0mp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
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