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>
<script type="module" crossorigin src="/assets/index-COMz6S13.js"></script> <script type="module" crossorigin src="/assets/index-CbmmhRNL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-EkAGiEK9.css"> <link rel="stylesheet" crossorigin href="/assets/index-D4unRNoP.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,19 +1,23 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles, Star } from "lucide-react"; import { ArrowLeft, BadgeCheck, CircleAlert, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable"; 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 { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes"; import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format"; import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountDetail } from "../types"; import type { AccountDetail } from "../types";
type Tab = "profile" | "devices" | "actions";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null); const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tab, setTab] = useState<Tab>("profile");
const [months, setMonths] = useState("1"); const [months, setMonths] = useState("1");
const [starsAmount, setStarsAmount] = useState("1000"); const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000))); 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(() => { useEffect(() => {
void load(); void load();
setTab("profile");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id]);
if (error) { if (error) {
@ -50,165 +56,218 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
} }
const account = detail.Account; 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 ( return (
<PageFrame <PageFrame
title={`Account #${account.ID}`} title={`Account #${account.ID}`}
eyebrow={"Account Profile"} eyebrow={"Account Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>}
> >
<SplitLayout <section className="entity-head">
main={ <div className="entity-head-main">
<div className="stacked-sections"> <Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} />
<section className="entity-head"> <div>
<div> <div className="entity-title">{displayName(account)}</div>
<div className="entity-title">{displayName(account)}</div> <div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div> {account.Collectibles?.length > 0 && (
{account.Collectibles?.length > 0 && ( <div className="entity-subtitle">
<div className="entity-subtitle"> <UsernameCell username="" collectibles={account.Collectibles} />
<UsernameCell username="" collectibles={account.Collectibles} /> </div>
</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>
<div className="entity-badges"> </section>
{account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
{detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <section className="section-block">
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} /> <SectionHead title={"Premium & Stars"} />
{account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>} <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> </div>
</section> </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"> <section className="section-block">
<SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} /> <SectionHead title={"Verification & Moderation Flags"} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} /> <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>
<section className="section-block"> <section className="section-block">
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} /> <SectionHead title={"Profile Attributes"} />
<AuditTable rows={detail.AuditLogs} /> <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> </section>
</div> </div>
}
side={ <section className="section-block">
<section className="action-dock"> <SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} action={<ScrollText size={16} />} />
<div className="dock-title">{"Account Actions"}</div> <AuditTable rows={detail.AuditLogs} />
<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> </section>
} </div>
/> )}
</PageFrame> </PageFrame>
); );
} }

View file

@ -21,6 +21,18 @@
border-radius: var(--radius); 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 { .entity-title {
color: var(--heading); color: var(--heading);
font-size: 20px; font-size: 20px;
@ -48,6 +60,13 @@
border-radius: var(--radius); border-radius: var(--radius);
} }
.action-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
align-items: start;
}
.section-block, .section-block,
.action-dock, .action-dock,
.surface { .surface {
@ -704,6 +723,10 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.attr-block + .attr-block {
margin-top: 10px;
}
.attr-block .duration-field input, .attr-block .duration-field input,
.duration-field select { .duration-field select {
width: 100%; width: 100%;