accout page cleanup

This commit is contained in:
onysd 2026-08-05 03:41:23 +03:00
parent c91fb5ae92
commit 21f5e888f2
7 changed files with 241 additions and 159 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-COMz6S13.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EkAGiEK9.css">
<script type="module" crossorigin src="/assets/index-CbmmhRNL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4unRNoP.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,19 +1,23 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles, Star } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, BadgeCheck, CircleAlert, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary, UsernameCell } from "../components/ui";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary, UsernameCell } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
type Tab = "profile" | "devices" | "actions";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
@ -40,6 +44,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
useEffect(() => {
void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
if (error) {
@ -50,165 +56,218 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
}
const account = detail.Account;
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
{ key: "devices", label: "Authorized Devices", icon: <MonitorSmartphone size={15} /> },
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
];
return (
<PageFrame
title={`Account #${account.ID}`}
eyebrow={"Account Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
{account.Collectibles?.length > 0 && (
<div className="entity-subtitle">
<UsernameCell username="" collectibles={account.Collectibles} />
</div>
<section className="entity-head">
<div className="entity-head-main">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} />
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
{account.Collectibles?.length > 0 && (
<div className="entity-subtitle">
<UsernameCell username="" collectibles={account.Collectibles} />
</div>
)}
</div>
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
{detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>}
</div>
</section>
<div className="toolbar" role="group" aria-label={"Account sections"}>
{tabs.map((item) => (
<button
key={item.key}
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
>
{item.icon} {item.label}
</button>
))}
</div>
{tab === "profile" && (
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"User ID"} value={String(account.ID)} mono />
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={"Restriction"} value={detail.HasRestriction ? detail.Restriction.Reason || "Restricted" : "None"} />
<Summary label={"Frozen since"} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : "None"} />
<Summary label={"Appeal deadline"} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : "None"} />
<Summary label={"Appeal URL"} value={detail.Restriction.AppealURL || "None"} />
<Summary label={"Created"} value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
</div>
)}
{tab === "devices" && (
<section className="section-block">
<SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
)}
{tab === "actions" && (
<div className="stacked-sections">
<div className="action-groups">
<section className="section-block">
<SectionHead title={"Freeze & Restriction"} text={"Blocks sign-in and marks the account for appeal review."} />
<div className="attr-block">
<label className="duration-field">
<span>{"Appeal deadline"}</span>
<input
aria-label={"Freeze appeal deadline"}
value={freezeUntil}
onChange={(event) => setFreezeUntil(event.target.value)}
type="datetime-local"
/>
</label>
<label className="duration-field">
<span>{"Appeal URL"}</span>
<input
aria-label={"Freeze appeal URL"}
value={freezeAppealURL}
onChange={(event) => setFreezeAppealURL(event.target.value)}
type="url"
placeholder="https://..."
/>
</label>
<ActionButton
label={account.Frozen ? "Update freeze" : "Freeze account"}
icon={<CircleAlert size={15} />}
tone="danger"
path="/api/actions/set-frozen"
payload={() => ({
user_id: account.ID,
frozen: true,
freeze_until: new Date(freezeUntil).toISOString(),
freeze_appeal_url: freezeAppealURL.trim()
})}
onDone={load}
/>
{account.Frozen && (
<ActionButton
label={"Unfreeze account"}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: false })}
onDone={load}
/>
)}
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
{detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>}
</section>
<section className="section-block">
<SectionHead title={"Premium & Stars"} />
<div className="attr-block">
<label className="duration-field">
<span>{"Premium duration (months)"}</span>
<input
aria-label={"Set premium duration in months"}
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label={"Set premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label={"Clear premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
</div>
</div>
<div className="attr-block">
<label className="duration-field">
<span>{"Stars to grant"}</span>
<input
aria-label={"Set Stars amount to grant"}
value={starsAmount}
onChange={(event) => setStarsAmount(event.target.value)}
type="number"
min="1"
max="1000000000"
/>
</label>
<ActionButton
label={"Grant Stars"}
icon={<Star size={15} />}
tone="warn"
path="/api/actions/grant-stars"
payload={() => ({ user_id: account.ID, amount: toInt(starsAmount) })}
onDone={load}
/>
</div>
</section>
<div className="summary-grid">
<Summary label={"User ID"} value={String(account.ID)} mono />
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={"Restriction"} value={detail.HasRestriction ? detail.Restriction.Reason || "Restricted" : "None"} />
<Summary label={"Frozen since"} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : "None"} />
<Summary label={"Appeal deadline"} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : "None"} />
<Summary label={"Appeal URL"} value={detail.Restriction.AppealURL || "None"} />
<Summary label={"Created"} value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
<SectionHead title={"Verification & Moderation Flags"} />
<div className="action-stack">
<ActionButton
label={detail.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
</div>
</section>
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} />
<SectionHead title={"Profile Attributes"} />
<div className="action-stack">
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={account.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Account Actions"}</div>
<label className="duration-field">
<span>{"Appeal deadline"}</span>
<input
aria-label={"Freeze appeal deadline"}
value={freezeUntil}
onChange={(event) => setFreezeUntil(event.target.value)}
type="datetime-local"
/>
</label>
<label className="duration-field">
<span>{"Appeal URL"}</span>
<input
aria-label={"Freeze appeal URL"}
value={freezeAppealURL}
onChange={(event) => setFreezeAppealURL(event.target.value)}
type="url"
placeholder="https://..."
/>
</label>
<ActionButton
label={account.Frozen ? "Update freeze" : "Freeze account"}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({
user_id: account.ID,
frozen: true,
freeze_until: new Date(freezeUntil).toISOString(),
freeze_appeal_url: freezeAppealURL.trim()
})}
onDone={load}
/>
{account.Frozen && (
<ActionButton
label={"Unfreeze account"}
icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: false })}
onDone={load}
/>
)}
<label className="duration-field">
<span>{"Premium duration (months)"}</span>
<input
aria-label={"Set premium duration in months"}
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label={"Set premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label={"Clear premium"}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
<label className="duration-field">
<span>{"Stars to grant"}</span>
<input
aria-label={"Set Stars amount to grant"}
value={starsAmount}
onChange={(event) => setStarsAmount(event.target.value)}
type="number"
min="1"
max="1000000000"
/>
</label>
<ActionButton
label={"Grant Stars"}
icon={<Star size={15} />}
tone="warn"
path="/api/actions/grant-stars"
payload={() => ({ user_id: account.ID, amount: toInt(starsAmount) })}
onDone={load}
/>
<ActionButton
label={detail.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
</div>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
<div className="dock-title">{"Attributes"}</div>
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={account.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
<section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<AuditTable rows={detail.AuditLogs} />
</section>
}
/>
</div>
)}
</PageFrame>
);
}

View file

@ -21,6 +21,18 @@
border-radius: var(--radius);
}
.entity-head-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.entity-head-main .avatar-photo-img,
.entity-head-main .avatar-fallback {
flex-shrink: 0;
}
.entity-title {
color: var(--heading);
font-size: 20px;
@ -48,6 +60,13 @@
border-radius: var(--radius);
}
.action-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
align-items: start;
}
.section-block,
.action-dock,
.surface {
@ -704,6 +723,10 @@
border-radius: var(--radius-sm);
}
.attr-block + .attr-block {
margin-top: 10px;
}
.attr-block .duration-field input,
.duration-field select {
width: 100%;