Add player bug reports and profile identity features

This commit is contained in:
2026-10-08 18:27:20 +05:00
parent 36e8d11eb4
commit 0b08672f4a
51 changed files with 3938 additions and 45 deletions

View File

@@ -0,0 +1,218 @@
"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>
);
}