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

@@ -7,13 +7,20 @@ import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
import {
defaultGlowColor,
type ProfileAppearance,
} from "@/shared/profile-appearance";
import { Username } from "./Username";
export function AccountSettings({
email,
displayName,
appearance,
passwordUpdated = false,
}: {
email: string;
displayName: string;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
@@ -25,6 +32,25 @@ export function AccountSettings({
[recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false);
const router = useRouter();
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""),
[colorSaved, setColorSaved] = useState(false);
async function saveColor(event: React.FormEvent) {
event.preventDefault();
setColorBusy(true);
setColorError("");
setColorSaved(false);
try {
await api("profile", { glowColor: color }, "PATCH");
setColorSaved(true);
router.refresh();
} catch (error) {
setColorError((error as Error).message);
} finally {
setColorBusy(false);
}
}
async function save(event: React.FormEvent) {
event.preventDefault();
setError("");
@@ -119,6 +145,49 @@ export function AccountSettings({
</button>
</form>
</section>
<section className="settings-card glow-settings">
<h2>{t("Username glow")}</h2>
<p className="muted">
{appearance.name_glow
? t("Choose the color of your glow.")
: t("Support can award a glow for a confirmed bug report.")}
</p>
<div className="username-preview">
<Username
name={name}
appearance={{ ...appearance, glow_color: color }}
/>
</div>
{appearance.name_glow && (
<form onSubmit={saveColor}>
<label className="field">
{t("Glow color")}
<input
type="color"
value={color}
onChange={(event) => {
setColor(event.target.value);
setColorSaved(false);
}}
disabled={colorBusy}
/>
</label>
{colorError && (
<p role="alert" className="error-message">
{t(colorError)}
</p>
)}
{colorSaved && (
<p role="status" className="success-message">
{t("Glow color saved.")}
</p>
)}
<button className="button primary" disabled={colorBusy}>
{colorBusy ? t("Saving…") : t("Save glow color")}
</button>
</form>
)}
</section>
<section className="settings-card">
<div className="settings-heading">
<LockKeyhole size={22} />

View File

@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import { Username } from "./Username";
import {
type LeaderboardData,
type LeaderboardEntry,
@@ -105,7 +106,7 @@ export function LeaderboardTable({
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<span className="player-name">
{entry.display_name}
<Username name={entry.display_name} appearance={entry} />
{entry.player_id === playerId && (
<span className="you-label">{t("You")}</span>
)}
@@ -152,7 +153,9 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
})}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<h2>
<Username name={entry.display_name} appearance={entry} />
</h2>
<p>
{formatNumber(entry.best_score)} <span>/ 25,000</span>
</p>

View File

@@ -3,6 +3,7 @@ import { useI18n } from "@/components/I18nProvider";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight, MapPin } from "lucide-react";
import { Username } from "./Username";
export type CardMap = {
id: string;
name: string;
@@ -11,6 +12,8 @@ export type CardMap = {
panorama_count: number;
image?: string | null;
credit?: string;
creditGlow?: boolean;
creditAppearance?: import("@/shared/profile-appearance").ProfileAppearance;
server?: { name: string } | undefined;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
};
@@ -51,7 +54,12 @@ export function MapCard({
<p>{map.description}</p>
{map.credit && (
<p className="map-credit">
{t("By")} {map.credit}
{t("By")}{" "}
<Username
name={map.credit}
appearance={map.creditAppearance}
glow={map.creditGlow}
/>
</p>
)}
<div className="card-meta">

View File

@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
import { ModeratorSettings } from "./ModeratorSettings";
import { ModeratorPrefixes } from "./ModeratorPrefixes";
type ReviewMap = {
id: string;
name: string;
@@ -114,6 +115,7 @@ export function Moderation() {
return (
<>
{authorized && <ModeratorSettings />}
{authorized && <ModeratorPrefixes />}
{error && (
<p role="alert" className="error-message">
{t(error)}

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>
);
}

View File

@@ -1,4 +1,5 @@
"use client";
import { Username } from "./Username";
import { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -139,7 +140,7 @@ export function MultiplayerGame({
{room.players.map((player) => (
<li key={player.id}>
<span>
{player.name}
<Username name={player.name} appearance={player.appearance} />
{player.id === room.you && <small> {t("(you)")}</small>}
<small>
{player.left

View File

@@ -11,6 +11,7 @@ import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
import type { RoomView } from "@/shared/multiplayer";
import { Username } from "./Username";
export function MultiplayerRoom({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
@@ -247,7 +248,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
{player.id === room.hostId && (
<Crown size={17} aria-label={t("Room owner")} />
)}{" "}
{player.name}{" "}
<Username
name={player.name}
appearance={player.appearance}
/>{" "}
{player.id === room.you && <small>{t("(you)")}</small>}
</span>
{owner && player.id !== room.you && (
@@ -304,7 +308,16 @@ export function MultiplayerRoom({ id }: { id: string }) {
<>
<p className="lead">
<Trophy size={24} aria-hidden="true" />{" "}
{winners.map((player) => player.name).join(" & ")} ·{" "}
{winners.map((player, index) => (
<span key={player.id}>
{index > 0 ? " & " : ""}
<Username
name={player.name}
appearance={player.appearance}
/>
</span>
))}{" "}
·{" "}
{t("{count} points", {
count: formatNumber(winners[0]?.total ?? 0),
})}
@@ -334,7 +347,10 @@ export function MultiplayerRoom({ id }: { id: string }) {
>
<td>{player.rank}</td>
<td>
{player.name}
<Username
name={player.name}
appearance={player.appearance}
/>
{player.left && <small> · {t("Left room")}</small>}
</td>
{room.rounds.map((turn) => (

View File

@@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Bug } from "lucide-react";
import { useI18n } from "./I18nProvider";
export function ReportBugButton() {
const { t } = useI18n();
const pathname = usePathname();
return (
<Link
className="header-report"
href={`/support?from=${encodeURIComponent(pathname)}`}
aria-label={t("Report a bug")}
>
<Bug size={18} aria-hidden="true" />
<span>{t("Report a bug")}</span>
</Link>
);
}

View File

@@ -1,4 +1,5 @@
"use client";
import { Username } from "./Username";
import { useCallback, useEffect, useRef, useState } from "react";
import { MessageCircle, Send, X } from "lucide-react";
@@ -197,7 +198,7 @@ export function RoomChat({
className={message.playerId === playerId ? "room-chat-mine" : ""}
>
<strong>
{message.name}
<Username name={message.name} appearance={message.appearance} />
{message.playerId === playerId && ` ${t("(you)")}`}
</strong>
<span>{message.text}</span>

View File

@@ -6,6 +6,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
import { SiteNav } from "./SiteNav";
import { ReportBugButton } from "./ReportBugButton";
export async function Header() {
const { t } = await getI18n();
const account = await user();
@@ -14,12 +15,15 @@ export async function Header() {
<div className="header-inner">
<Brand />
<SiteNav />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
</Link>
<div className="header-actions">
<ReportBugButton />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
>
{account ? t("My account") : t("Log in")} <ArrowUpRight size={15} />
</Link>
</div>
</div>
</header>
);

465
components/Support.tsx Normal file
View File

@@ -0,0 +1,465 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bug, ImagePlus, Send, Sparkles } from "lucide-react";
import { api } from "@/lib/client-api";
import {
bugReportSchema,
bugMessageSchema,
screenshotLimit,
type BugReportView,
} from "@/shared/bug-reports";
import { useI18n } from "./I18nProvider";
import { Username } from "./Username";
type Inbox = {
ready: boolean;
signedIn: boolean;
displayName: string | null;
nameGlow: boolean;
reports: {
id: string;
title: string;
state: string;
created_at: string;
linear_identifier: string | null;
}[];
};
export function SupportInbox({ pagePath }: { pagePath: string }) {
const { t, formatDate } = useI18n(),
router = useRouter();
const [inbox, setInbox] = useState<Inbox | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [title, setTitle] = useState(""),
[description, setDescription] = useState(""),
[name, setName] = useState(""),
[file, setFile] = useState<File | null>(null);
const nonce = useRef<string | null>(null);
useEffect(() => {
let stopped = false;
api<Inbox>("bug-reports")
.then((value) => {
if (!stopped) setInbox(value);
})
.catch((failure) => {
if (!stopped) setError(failure.message);
});
return () => {
stopped = true;
};
}, []);
async function submit() {
if (busy) return;
const clientId = (nonce.current ??= crypto.randomUUID());
const body = {
clientId,
title,
description,
pagePath,
displayName: inbox?.signedIn ? undefined : name,
screenshot: !!file,
};
if (!bugReportSchema.safeParse(body).success) {
setError(
"Add a short title, at least 15 characters describing the bug, and your player name.",
);
return;
}
if (
file &&
(!["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
file.size > screenshotLimit)
) {
setError("Choose a PNG, JPG, or WebP screenshot smaller than 5 MB");
return;
}
setBusy(true);
setError("");
try {
const report = await api<{
id: string;
state: string;
upload: string | null;
}>("bug-reports", body);
if (report.upload && file) {
const response = await fetch(report.upload, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
signal: AbortSignal.timeout(60_000),
});
if (!response.ok)
throw new Error("Screenshot upload failed. Please try again.");
}
await api(`bug-reports/${report.id}/submit`, {});
router.push(`/support/${report.id}`);
} catch (failure) {
setError((failure as Error).message);
setBusy(false);
}
}
return (
<div className="support-layout">
<section className="support-form">
<span className="eyebrow">
<Bug size={16} />
{t("PLAYER SUPPORT")}
</span>
<h1>{t("Found a bug?")}</h1>
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
{!inbox?.signedIn && (
<p className="muted">
{t(
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
)}{" "}
<Link href="/log-in?next=%2Fsupport">{t("Log in")}</Link>
</p>
)}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
{inbox && !inbox.ready && (
<p role="status">
{t(
"Bug reporting is temporarily unavailable. Please try again later.",
)}
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{!inbox?.signedIn && (
<>
<label htmlFor="report-name">{t("Your player name")}</label>
<input
id="report-name"
value={name}
onChange={(event) => {
nonce.current = null;
setName(event.target.value);
}}
required
minLength={2}
maxLength={32}
autoComplete="nickname"
disabled={busy}
/>
</>
)}
<label htmlFor="bug-title">{t("Bug title")}</label>
<input
id="bug-title"
value={title}
onChange={(event) => {
nonce.current = null;
setTitle(event.target.value);
}}
minLength={5}
maxLength={120}
required
disabled={busy}
/>
<label htmlFor="bug-description">{t("What happened?")}</label>
<textarea
id="bug-description"
value={description}
onChange={(event) => {
nonce.current = null;
setDescription(event.target.value);
}}
minLength={15}
maxLength={6000}
required
rows={7}
placeholder={t(
"What did you do, what did you expect, and what happened instead?",
)}
disabled={busy}
/>
<label htmlFor="bug-screenshot">
<ImagePlus size={16} /> {t("Screenshot (optional)")}
</label>
<input
id="bug-screenshot"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={busy}
onChange={(event) => {
nonce.current = null;
setFile(event.target.files?.[0] ?? null);
}}
/>
<small>
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
</small>
<button className="button primary" disabled={busy || !inbox?.ready}>
<Send size={17} />
{busy ? t("Sending…") : t("Send bug report")}
</button>
</form>
</section>
<section className="support-inbox">
<h2>{t("Your bug reports")}</h2>
{!inbox ? (
<p className="muted">{t("Loading…")}</p>
) : inbox.reports.length === 0 ? (
<p className="muted">
{t("Your reports and support replies will appear here.")}
</p>
) : (
<ul>
{inbox.reports.map((report) => (
<li key={report.id}>
<Link href={`/support/${report.id}`}>
<strong>{report.title}</strong>
<small>
{formatDate(report.created_at)} ·{" "}
{report.linear_identifier ??
t(
report.state === "draft"
? "Draft"
: "Waiting for support",
)}
</small>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
export function SupportConversation({ id }: { id: string }) {
const { t, formatDate } = useI18n();
const [report, setReport] = useState<BugReportView | null>(null),
[error, setError] = useState(""),
[text, setText] = useState(""),
[busy, setBusy] = useState(false);
const pending = useRef<{ clientId: string; text: string } | null>(null),
version = useRef(0),
acting = useRef(false);
const load = useCallback(async () => {
if (acting.current) return;
const current = ++version.current;
try {
const value = await api<BugReportView>(`bug-reports/${id}`);
if (current === version.current) {
setReport(value);
setError("");
}
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id]);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (!stopped) timer = setTimeout(poll, document.hidden ? 30_000 : 10_000);
};
void poll();
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
};
}, [load]);
async function action(kind: string, body: object) {
if (acting.current) return;
acting.current = true;
setBusy(true);
setError("");
const current = ++version.current;
try {
const value = await api<BugReportView>(`bug-reports/${id}/${kind}`, body);
if (current === version.current) {
setReport(value);
if (kind === "messages") {
setText("");
pending.current = null;
}
}
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
} finally {
acting.current = false;
setBusy(false);
}
}
async function send() {
const draft = text.trim();
const body =
pending.current?.text === draft
? pending.current
: { text: draft, clientId: crypto.randomUUID() };
if (!bugMessageSchema.safeParse(body).success) {
setError("Use 1–6000 characters without hidden controls");
return;
}
pending.current = body;
await action("messages", body);
}
return (
<div className="support-conversation">
<Link className="text-link" href="/support">
{t("← Your bug reports")}
</Link>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
{!report ? (
<p>{t("Loading…")}</p>
) : (
<>
<span className="eyebrow">
{t("PLAYER SUPPORT")} ·{" "}
{report.linear_identifier ?? t("Waiting for support")}
</span>
<h1>{report.title}</h1>
{report.state === "pending" && (
<p role="status">
{t("Your report is saved. We’re connecting it to support.")}
</p>
)}
{report.state === "draft" && (
<>
<p>
{t(
"This report is not sent yet. Return to the form or finish sending it.",
)}
</p>
<button
className="button primary"
disabled={busy}
onClick={() => void action("submit", {})}
>
{t("Finish sending report")}
</button>
</>
)}
{report.canClaim && (
<button
className="button"
disabled={busy}
onClick={() => void action("claim", {})}
>
{t("Keep this report in my account")}
</button>
)}
{report.glow_requested && (
<p className="support-reward">
<Sparkles size={18} />
{t("Support awarded you a username glow.")}{" "}
{!report.canClaim && (
<Link
href={
report.isGuest
? `/log-in?next=${encodeURIComponent(`/support/${id}`)}`
: "/settings"
}
>
{report.isGuest ? t("Log in") : t("Account settings")}
</Link>
)}
{report.isGuest && !report.canClaim && (
<small>
{t(
"For guest reports, sign in and link this report to receive your reward.",
)}
</small>
)}
</p>
)}
{report.screenshot && (
<a href={report.screenshot} target="_blank" rel="noreferrer">
<img
className="support-screenshot"
src={report.screenshot}
alt={t("Attached bug screenshot")}
/>
</a>
)}
<div
className="support-messages"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label={t("Support conversation")}
>
{report.messages.map((message) => (
<article
key={message.id}
className={`support-message support-message-${message.kind}`}
>
<header>
<strong>
{message.kind === "user" ? (
<Username
name={message.author_name}
appearance={report.authorAppearance}
/>
) : (
t("Support")
)}
</strong>
<small>{formatDate(message.created_at)}</small>
</header>
<p>
{message.kind === "system" ? t(message.body) : message.body}
</p>
{message.kind === "user" && (
<small>
{message.delivered
? t("Delivered to support")
: t("Saved · waiting to send")}
</small>
)}
</article>
))}
</div>
{report.state !== "draft" && (
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label htmlFor="support-message">{t("Message support")}</label>
<textarea
id="support-message"
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={6000}
rows={3}
required
disabled={busy}
/>
<button
className="button primary"
disabled={busy || !text.trim()}
>
<Send size={16} />
{t("Send message")}
</button>
</form>
)}
</>
)}
</div>
);
}

77
components/Username.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import {
Bug,
ShieldCheck,
Star,
Crown,
Heart,
Flame,
Gem,
Trophy,
Sparkles,
} from "lucide-react";
import { useI18n } from "./I18nProvider";
import { glowColor, type ProfileAppearance } from "@/shared/profile-appearance";
export const badgeIcons = {
star: Star,
crown: Crown,
heart: Heart,
flame: Flame,
gem: Gem,
trophy: Trophy,
sparkles: Sparkles,
};
export function Username({
name,
glow,
appearance = {},
}: {
name: string;
glow?: boolean;
appearance?: ProfileAppearance;
}) {
const { t } = useI18n();
const color = glowColor(appearance.glow_color),
enabled = glow ?? appearance.name_glow;
const Icon =
badgeIcons[appearance.custom_prefix_icon as keyof typeof badgeIcons] ??
Star;
return (
<span className="username">
{appearance.role === "moderator" && (
<span
className="username-prefix username-moderator"
title={t("Moderator")}
>
<ShieldCheck size={13} aria-hidden="true" />
{t("Moderator")}
</span>
)}
{appearance.bug_finder && (
<span
className="username-prefix username-bug-finder"
title={t("Bug finder")}
>
<Bug size={13} aria-hidden="true" />
{t("Bug finder")}
</span>
)}
{appearance.custom_prefix && (
<span className="username-prefix username-custom-prefix">
<Icon size={13} aria-hidden="true" />
{appearance.custom_prefix}
</span>
)}
<span
className={enabled ? "username-glow" : undefined}
style={
enabled
? { color, textShadow: `0 0 8px ${color}90, 0 0 18px ${color}60` }
: undefined
}
>
{name}
</span>
</span>
);
}