Move "Reserve username" into a modal opened from the page actions, and keep a single search toolbar in the query panel, so the page matches Collectible Usernames instead of stacking two toolbars with an unconstrained input.
173 lines
5.8 KiB
TypeScript
173 lines
5.8 KiB
TypeScript
import { AtSign, 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 Fragment badge - that is the collectible
|
|
// tab's job.
|
|
export function ReservedUsernamesPage() {
|
|
const [q, setQ] = useState("");
|
|
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [reserveOpen, setReserveOpen] = useState(false);
|
|
|
|
async function load() {
|
|
setBusy(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 {
|
|
setBusy(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={busy}>
|
|
<RefreshCw size={15} className={busy ? "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={busy}>
|
|
{busy ? <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 className="icon-text">
|
|
<AtSign size={13} />
|
|
{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>
|
|
);
|
|
}
|
|
|
|
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="duration-field">
|
|
<span>{"Username"}</span>
|
|
<input value={username} onChange={(event) => setUsername(event.target.value)} placeholder="support" />
|
|
</label>
|
|
<p className="bot-create-note">
|
|
{"No peer will be able to take this name 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
|
|
);
|
|
}
|