admin ui: self-contained reserve-username modal, plain @ text

The reserve modal delegated to a nested ActionButton, whose own flow modal
opened over it - the username field ended up behind it and the request preview
came through empty on confirm. Replace it with a modal that owns its username
and reason fields and posts the reserve/unreserve command directly. Render the
@ prefix as text, not an icon.
This commit is contained in:
Astra 2026-09-09 21:15:23 +01:00
parent 9ed8590264
commit d6d3be0070
2 changed files with 94 additions and 47 deletions

View file

@ -1,25 +1,24 @@
import { AtSign, Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react"; import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { formatUnix } from "../lib/format"; import { formatUnix } from "../lib/format";
import type { ReservedUsernameRow } from "../types"; import type { ReservedUsernameRow } from "../types";
// Reserved usernames are a plain operator blocklist: a name listed here cannot be // 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 // 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 // collectible. No owner, no price, no "bought on Fragment" badge - that is the
// tab's job. // collectible tab's job.
export function ReservedUsernamesPage() { export function ReservedUsernamesPage() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [reserveOpen, setReserveOpen] = useState(false); const [reserveOpen, setReserveOpen] = useState(false);
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function load() { async function load() {
setBusy(true); setLoading(true);
setError(""); setError("");
const params = new URLSearchParams({ limit: "200" }); const params = new URLSearchParams({ limit: "200" });
if (q.trim()) params.set("q", q.trim().replace(/^@/, "")); if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
@ -29,7 +28,7 @@ export function ReservedUsernamesPage() {
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
setBusy(false); setLoading(false);
} }
} }
@ -46,8 +45,8 @@ export function ReservedUsernamesPage() {
<button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}> <button className="btn primary icon-text" type="button" onClick={() => setReserveOpen(true)}>
<Plus size={15} /> {"Reserve username"} <Plus size={15} /> {"Reserve username"}
</button> </button>
<button className="btn icon-text" type="button" onClick={() => load()} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load()} disabled={loading}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"} <RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
</button> </button>
</> </>
} }
@ -62,15 +61,15 @@ export function ReservedUsernamesPage() {
className="toolbar" className="toolbar"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void load(); load();
}} }}
> >
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={loading}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"} {loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button> </button>
</form> </form>
</QueryPanel> </QueryPanel>
@ -89,26 +88,11 @@ export function ReservedUsernamesPage() {
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.username}> <tr key={row.username}>
<td> <td><strong>{`@${row.username}`}</strong></td>
<strong className="icon-text">
<AtSign size={13} />
{row.username}
</strong>
</td>
<td>{row.reason || "-"}</td> <td>{row.reason || "-"}</td>
<td>{row.actor || "-"}</td> <td>{row.actor || "-"}</td>
<td>{formatUnix(row.created_at) || "-"}</td> <td>{formatUnix(row.created_at) || "-"}</td>
<td> <td><UnreserveButton username={row.username} onDone={() => load()} /></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> </tr>
))} ))}
{rows.length === 0 && <EmptyRow colSpan={5} />} {rows.length === 0 && <EmptyRow colSpan={5} />}
@ -121,7 +105,7 @@ export function ReservedUsernamesPage() {
onClose={() => setReserveOpen(false)} onClose={() => setReserveOpen(false)}
onDone={() => { onDone={() => {
setReserveOpen(false); setReserveOpen(false);
void load(); load();
}} }}
/> />
)} )}
@ -129,9 +113,63 @@ export function ReservedUsernamesPage() {
); );
} }
function UnreserveButton({ username, onDone }: { username: string; onDone: () => void }) {
const [busy, setBusy] = useState(false);
return (
<button
className="btn danger compact-btn icon-text"
type="button"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Unreserve @${username}?`)) return;
setBusy(true);
try {
await api.action("/api/actions/unreserve-username", {
username,
reason: "unreserved from admin panel",
confirm: true,
});
onDone();
} catch (err) {
window.alert(errorMessage(err));
} finally {
setBusy(false);
}
}}
>
{busy ? <Loader2 size={13} className="spin" /> : <Trash2 size={13} />} {"Unreserve"}
</button>
);
}
function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) { function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const clean = username.trim().replace(/^@/, ""); const clean = username.trim().replace(/^@/, "");
const canSubmit = clean.length >= 5 && reason.trim().length > 0 && !busy;
async function submit() {
setBusy(true);
setError("");
try {
const result = await api.action("/api/actions/reserve-username", {
username: clean,
reason: reason.trim(),
confirm: true,
});
if (result.error) {
setError(result.error);
return;
}
onDone();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal( return createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
@ -146,28 +184,37 @@ function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone
</button> </button>
</div> </div>
<div className="command-body"> <div className="command-body">
<label className="duration-field"> <label className="form-field">
<span>{"Username"}</span> <span>{"Username"}</span>
<input value={username} onChange={(event) => setUsername(event.target.value)} placeholder="support" /> <input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="support"
autoFocus
/>
</label>
<label className="form-field">
<span>{"Reason"}</span>
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={"Why this name is off limits"}
/>
</label> </label>
<p className="bot-create-note"> <p className="bot-create-note">
{"No peer will be able to take this name until it is unreserved. Nothing is shown to users."} {"No peer will be able to take @"}{clean || "…"}{" until it is unreserved. Nothing is shown to users."}
</p> </p>
{error && <Alert>{error}</Alert>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button> <button className="btn" type="button" onClick={onClose}>{"Close"}</button>
<ActionButton <button className="btn primary icon-text" type="button" disabled={!canSubmit} onClick={() => void submit()}>
disabled={clean.length < 5} {busy ? <Loader2 size={15} className="spin" /> : <Plus size={15} />} {"Reserve username"}
label={"Reserve username"} </button>
icon={<Plus size={15} />}
tone="neutral"
path="/api/actions/reserve-username"
payload={() => ({ username: clean })}
onDone={onDone}
/>
</div> </div>
</section> </section>
</div>, </div>,
document.body document.body,
); );
} }