Both reserve and unreserve go through ActionButton now (reason -> dry-run -> confirm, journalled) like every other admin action. The reserve modal keeps just the username field and hands off; it autofocuses and echoes @<name> live so the field being filled is unambiguous.
176 lines
6 KiB
TypeScript
176 lines
6 KiB
TypeScript
import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { api, errorMessage } from "../api";
|
|
import { ActionButton } from "../components/ActionButton";
|
|
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
|
import { formatUnix } from "../lib/format";
|
|
import type { ReservedUsernameRow } from "../types";
|
|
|
|
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
|
|
// taken as an editable username by any peer and cannot be minted as a
|
|
// collectible. No owner, no price, no "bought on Fragment" badge - that is the
|
|
// collectible tab's job.
|
|
export function ReservedUsernamesPage() {
|
|
const [q, setQ] = useState("");
|
|
const [reserveOpen, setReserveOpen] = useState(false);
|
|
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError("");
|
|
const params = new URLSearchParams({ limit: "200" });
|
|
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
|
try {
|
|
const result = await api.reservedUsernames(params);
|
|
setRows(result.reserved ?? []);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, []);
|
|
|
|
return (
|
|
<PageFrame
|
|
title={"Reserved usernames"}
|
|
eyebrow={"Usernames / Blocklist"}
|
|
actions={
|
|
<>
|
|
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
|
|
<Plus size={15} /> {"Reserve username"}
|
|
</button>
|
|
<button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
|
|
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
{error && <Alert>{error}</Alert>}
|
|
<div className="metric-row">
|
|
<Metric label={"Reserved names"} value={String(rows.length)} />
|
|
</div>
|
|
|
|
<QueryPanel>
|
|
<form
|
|
className="toolbar"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void load();
|
|
}}
|
|
>
|
|
<label className="searchbox">
|
|
<Search size={15} />
|
|
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
|
|
</label>
|
|
<button className="btn primary icon-text" type="submit" disabled={loading}>
|
|
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
|
</button>
|
|
</form>
|
|
</QueryPanel>
|
|
|
|
<div className="table-wrap">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{"Username"}</th>
|
|
<th>{"Reason"}</th>
|
|
<th>{"Reserved by"}</th>
|
|
<th>{"Reserved (UTC)"}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.username}>
|
|
<td><strong>{`@${row.username}`}</strong></td>
|
|
<td>{row.reason || "-"}</td>
|
|
<td>{row.actor || "-"}</td>
|
|
<td>{formatUnix(row.created_at) || "-"}</td>
|
|
<td>
|
|
<ActionButton
|
|
compact
|
|
label={"Unreserve"}
|
|
icon={<Trash2 size={13} />}
|
|
tone="danger"
|
|
path="/api/actions/unreserve-username"
|
|
payload={() => ({ username: row.username })}
|
|
onDone={() => void load()}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{reserveOpen && (
|
|
<ReserveUsernameModal
|
|
onClose={() => setReserveOpen(false)}
|
|
onDone={() => {
|
|
setReserveOpen(false);
|
|
void load();
|
|
}}
|
|
/>
|
|
)}
|
|
</PageFrame>
|
|
);
|
|
}
|
|
|
|
// ReserveUsernameModal collects the name, then hands off to ActionButton for the
|
|
// standard reason / dry-run / confirm flow - the same as every other admin
|
|
// action. The name is read fresh from state on each ActionButton render.
|
|
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
|
const [username, setUsername] = useState("");
|
|
const clean = username.trim().replace(/^@/, "");
|
|
|
|
return createPortal(
|
|
<div className="modal-backdrop" role="presentation">
|
|
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Reserve a username"}>
|
|
<div className="modal-head">
|
|
<div>
|
|
<div className="eyebrow">{"Usernames"}</div>
|
|
<h2>{"Reserve a username"}</h2>
|
|
</div>
|
|
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
<div className="command-body">
|
|
<label className="form-field">
|
|
<span>{"Username"}</span>
|
|
<input
|
|
value={username}
|
|
onChange={(event) => setUsername(event.target.value)}
|
|
placeholder="support"
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<p className="bot-create-note">
|
|
{`No peer will be able to take @${clean || "…"} until it is unreserved. Nothing is shown to users.`}
|
|
</p>
|
|
</div>
|
|
<div className="modal-actions">
|
|
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
|
<ActionButton
|
|
disabled={clean.length < 5}
|
|
label={"Reserve username"}
|
|
icon={<Plus size={15} />}
|
|
tone="neutral"
|
|
path="/api/actions/reserve-username"
|
|
payload={() => ({ username: clean })}
|
|
onDone={onDone}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|