Compare commits
No commits in common. "fa95dab29a5647e69b2f9f7ead26fb696fc6d44a" and "2f7ad81ae44f6ab36d321b91d7911b49415f896b" have entirely different histories.
fa95dab29a
...
2f7ad81ae4
3 changed files with 47 additions and 94 deletions
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-v1d-K3Z6.js"></script>
|
<script type="module" crossorigin src="/assets/index-BYKuPGzl.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,25 @@
|
||||||
import { Loader2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
import { AtSign, 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 "bought on Fragment" badge - that is the
|
// collectible. No owner, no price, no Fragment badge - that is the collectible
|
||||||
// collectible tab's job.
|
// tab's job.
|
||||||
export function ReservedUsernamesPage() {
|
export function ReservedUsernamesPage() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [reserveOpen, setReserveOpen] = useState(false);
|
|
||||||
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [reserveOpen, setReserveOpen] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setBusy(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(/^@/, ""));
|
||||||
|
|
@ -28,7 +29,7 @@ export function ReservedUsernamesPage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,8 +46,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={loading}>
|
<button className="btn icon-text" type="button" onClick={() => load()} disabled={busy}>
|
||||||
<RefreshCw size={15} className={loading ? "spin" : ""} /> {"Refresh"}
|
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
@ -61,15 +62,15 @@ export function ReservedUsernamesPage() {
|
||||||
className="toolbar"
|
className="toolbar"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
load();
|
void 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={loading}>
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
{loading ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
@ -88,11 +89,26 @@ export function ReservedUsernamesPage() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.username}>
|
<tr key={row.username}>
|
||||||
<td><strong>{`@${row.username}`}</strong></td>
|
<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><UnreserveButton username={row.username} onDone={() => load()} /></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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
||||||
|
|
@ -105,7 +121,7 @@ export function ReservedUsernamesPage() {
|
||||||
onClose={() => setReserveOpen(false)}
|
onClose={() => setReserveOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setReserveOpen(false);
|
setReserveOpen(false);
|
||||||
load();
|
void load();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -113,63 +129,9 @@ 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">
|
||||||
|
|
@ -184,37 +146,28 @@ function ReserveUsernameModal({ onClose, onDone }: { onClose: () => void; onDone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="command-body">
|
<div className="command-body">
|
||||||
<label className="form-field">
|
<label className="duration-field">
|
||||||
<span>{"Username"}</span>
|
<span>{"Username"}</span>
|
||||||
<input
|
<input value={username} onChange={(event) => setUsername(event.target.value)} placeholder="support" />
|
||||||
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 @"}{clean || "…"}{" until it is unreserved. Nothing is shown to users."}
|
{"No peer will be able to take this name 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>
|
||||||
<button className="btn primary icon-text" type="button" disabled={!canSubmit} onClick={() => void submit()}>
|
<ActionButton
|
||||||
{busy ? <Loader2 size={15} className="spin" /> : <Plus size={15} />} {"Reserve username"}
|
disabled={clean.length < 5}
|
||||||
</button>
|
label={"Reserve username"}
|
||||||
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue