219 lines
6.3 KiB
TypeScript
219 lines
6.3 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import { Tags } from "lucide-react";
|
||
import { useI18n } from "./I18nProvider";
|
||
import { api } from "@/lib/client-api";
|
||
import {
|
||
prefixIcons,
|
||
prefixUpdateSchema,
|
||
type BadgeUser,
|
||
type PrefixIcon,
|
||
} from "@/shared/profile-appearance";
|
||
import { Username } from "./Username";
|
||
|
||
export function ModeratorPrefixes() {
|
||
const { t } = useI18n();
|
||
const [search, setSearch] = useState(""),
|
||
[users, setUsers] = useState<BadgeUser[]>([]),
|
||
[selected, setSelected] = useState<BadgeUser | null>(null);
|
||
const [prefix, setPrefix] = useState(""),
|
||
[icon, setIcon] = useState<PrefixIcon>("star"),
|
||
[busy, setBusy] = useState(false),
|
||
[error, setError] = useState(""),
|
||
[saved, setSaved] = useState(false),
|
||
[searched, setSearched] = useState(false);
|
||
async function find(event: React.FormEvent) {
|
||
event.preventDefault();
|
||
setBusy(true);
|
||
setError("");
|
||
setSaved(false);
|
||
setSelected(null);
|
||
try {
|
||
const result = await api<{ users: BadgeUser[] }>(
|
||
"moderation/users?q=" + encodeURIComponent(search),
|
||
);
|
||
setUsers(result.users);
|
||
setSearched(true);
|
||
} catch (failure) {
|
||
setError((failure as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
async function save(remove = false) {
|
||
if (!selected || busy) return;
|
||
const body = prefixUpdateSchema.safeParse({
|
||
prefix: remove ? null : prefix,
|
||
icon,
|
||
});
|
||
if (!body.success) {
|
||
setError(
|
||
"Use a prefix of 1–24 characters without hidden controls or markup",
|
||
);
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError("");
|
||
setSaved(false);
|
||
try {
|
||
const result = await api<{ user: BadgeUser }>(
|
||
"moderation/users/" + selected.id,
|
||
body.data,
|
||
"PATCH",
|
||
);
|
||
setSelected(result.user);
|
||
setPrefix(result.user.custom_prefix ?? "");
|
||
setUsers((users) =>
|
||
users.map((user) => (user.id === result.user.id ? result.user : user)),
|
||
);
|
||
setSaved(true);
|
||
} catch (failure) {
|
||
setError((failure as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
const iconLabels = {
|
||
star: t("Star"),
|
||
crown: t("Crown"),
|
||
heart: t("Heart"),
|
||
flame: t("Flame"),
|
||
gem: t("Gem"),
|
||
trophy: t("Trophy"),
|
||
sparkles: t("Sparkles"),
|
||
};
|
||
return (
|
||
<section
|
||
className="tool-card moderator-prefixes"
|
||
aria-labelledby="prefix-heading"
|
||
>
|
||
<div className="settings-heading">
|
||
<Tags size={24} aria-hidden="true" />
|
||
<div>
|
||
<h2 id="prefix-heading">{t("Player prefixes")}</h2>
|
||
<p>{t("Find a player and assign a custom prefix with an icon.")}</p>
|
||
</div>
|
||
</div>
|
||
<form onSubmit={find} className="prefix-search">
|
||
<label className="field">
|
||
{t("Player name or account ID")}
|
||
<input
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
maxLength={64}
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
<button className="button" disabled={busy}>
|
||
{t("Find players")}
|
||
</button>
|
||
</form>
|
||
{searched && users.length === 0 && (
|
||
<p className="muted">{t("No players found.")}</p>
|
||
)}
|
||
{users.length > 0 && (
|
||
<ul className="prefix-player-list">
|
||
{users.map((user) => (
|
||
<li key={user.id}>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
aria-pressed={selected?.id === user.id}
|
||
onClick={() => {
|
||
setSelected(user);
|
||
setPrefix(user.custom_prefix ?? "");
|
||
setIcon(
|
||
prefixIcons.includes(user.custom_prefix_icon as PrefixIcon)
|
||
? (user.custom_prefix_icon as PrefixIcon)
|
||
: "star",
|
||
);
|
||
setSaved(false);
|
||
setError("");
|
||
}}
|
||
>
|
||
<Username name={user.display_name} appearance={user} />
|
||
<small>{user.id}</small>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{selected && (
|
||
<form
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void save();
|
||
}}
|
||
className="prefix-editor"
|
||
>
|
||
<label className="field">
|
||
{t("Custom prefix")}
|
||
<input
|
||
value={prefix}
|
||
onChange={(event) => {
|
||
setPrefix(event.target.value);
|
||
setSaved(false);
|
||
}}
|
||
minLength={1}
|
||
maxLength={24}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
<div className="field">
|
||
<label htmlFor="custom-prefix-icon">{t("Prefix icon")}</label>
|
||
<select
|
||
id="custom-prefix-icon"
|
||
value={icon}
|
||
onChange={(event) => {
|
||
setIcon(event.target.value as PrefixIcon);
|
||
setSaved(false);
|
||
}}
|
||
disabled={busy}
|
||
>
|
||
{prefixIcons.map((value) => (
|
||
<option key={value} value={value}>
|
||
{iconLabels[value]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="username-preview">
|
||
<Username
|
||
name={selected.display_name}
|
||
appearance={{
|
||
...selected,
|
||
custom_prefix: prefix,
|
||
custom_prefix_icon: icon,
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="inline-actions">
|
||
<button className="button primary" disabled={busy}>
|
||
{t("Save prefix")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="button"
|
||
disabled={busy || !selected.custom_prefix}
|
||
onClick={() => void save(true)}
|
||
>
|
||
{t("Remove prefix")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
{error && (
|
||
<p role="alert" className="error-message">
|
||
{t(error)}
|
||
</p>
|
||
)}
|
||
{saved && (
|
||
<p role="status" className="success-message">
|
||
{t("Player prefix saved.")}
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|