Files
MineGeo/components/AccountSettings.tsx
pavel444-byte 50b27cf498
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 3m27s
Add moderator bots and editable MineGeo usernames
2026-10-29 16:32:55 +05:00

360 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { UserRound, LockKeyhole, Mail } from "lucide-react";
import { api } from "./StartGame";
import { PasswordForm } from "./PasswordForm";
import { displayNameSchema } from "@/shared/account";
import {
defaultGlowColor,
profileAvatarMaxBytes,
profileAvatarMimeTypes,
type ProfileAppearance,
} from "@/shared/profile-appearance";
import { Username } from "./Username";
import { PlayerAvatar } from "./PixelArt";
export function AccountSettings({
accountId,
email,
displayName,
username,
avatarPath,
appearance,
passwordUpdated = false,
}: {
accountId: string;
email: string;
displayName: string;
username: string | null;
avatarPath: string | null;
appearance: ProfileAppearance;
passwordUpdated?: boolean;
}) {
const { t } = useI18n();
const [name, setName] = useState(displayName),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [recoveryMessage, setRecoveryMessage] = useState(""),
[recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false);
const router = useRouter();
const [handle, setHandle] = useState(username ?? "");
const [handleMessage, setHandleMessage] = useState("");
const [handleBusy, setHandleBusy] = useState(false);
async function saveHandle(event: React.FormEvent) {
event.preventDefault();
setHandleBusy(true);
setHandleMessage("");
try {
const result = await api<{ profile: { username: string } }>("profile", { username: handle }, "PATCH");
setHandle(result.profile.username);
setHandleMessage("Username saved.");
router.refresh();
} catch (e) { setHandleMessage((e as Error).message); }
finally { setHandleBusy(false); }
}
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""),
[colorSaved, setColorSaved] = useState(false);
const [photoPath, setPhotoPath] = useState(avatarPath),
[photoBusy, setPhotoBusy] = useState(false),
[photoError, setPhotoError] = useState(""),
[photoSaved, setPhotoSaved] = useState(false);
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const file = input.files?.[0];
input.value = "";
setPhotoError("");
setPhotoSaved(false);
if (!file) return;
if (file.size > profileAvatarMaxBytes) {
setPhotoError("Images must be 5 MB or smaller.");
return;
}
if (
!profileAvatarMimeTypes.includes(
file.type as (typeof profileAvatarMimeTypes)[number],
)
) {
setPhotoError("Choose a JPG, PNG or WebP image.");
return;
}
setPhotoBusy(true);
try {
const form = new FormData();
form.append("photo", file);
const result = await api<{ profile: { avatar_path: string } }>(
"profile/avatar",
form,
);
setPhotoPath(result.profile.avatar_path);
setPhotoSaved(true);
router.refresh();
} catch (error) {
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(false);
}
}
async function removeAvatar() {
setPhotoBusy(true);
setPhotoError("");
setPhotoSaved(false);
try {
await api("profile", { avatarPath: null }, "PATCH");
setPhotoPath(null);
setPhotoSaved(true);
router.refresh();
} catch (error) {
setPhotoError((error as Error).message);
} finally {
setPhotoBusy(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("");
setMessage("");
const valid = displayNameSchema.safeParse(name);
if (!valid.success) {
setError(valid.error.issues[0].message);
return;
}
setBusy(true);
try {
const data = await api<{ profile: { display_name: string } }>(
"profile",
{ displayName: valid.data },
"PATCH",
);
setName(data.profile.display_name);
setMessage("Display name saved.");
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function recover() {
setSending(true);
setRecoveryError("");
try {
const data = await api<{ message: string }>("auth", {
action: "recover",
email,
});
setRecoveryMessage(data.message);
} catch (e) {
setRecoveryError((e as Error).message);
} finally {
setSending(false);
}
}
return (
<div className="settings-grid">
<section className="settings-card">
<div className="settings-heading">
<UserRound size={22} />
<div>
<h2>{t("Your public profile.")}</h2>
<p className="muted">
{t("A name for the maps and places you share.")}
</p>
</div>
</div>
<form onSubmit={save}>
<div className="account-avatar-control">
<PlayerAvatar
id={accountId}
avatarPath={photoPath}
className="account-avatar-preview"
/>
<div className="account-avatar-input">
<label className="field">
{t("Profile photo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={uploadAvatar}
disabled={photoBusy}
/>
<small>{t("JPG, PNG or WebP · Maximum size: 5 MB.")}</small>
</label>
{photoPath && (
<button
type="button"
className="button"
onClick={removeAvatar}
disabled={photoBusy}
>
{t("Remove photo")}
</button>
)}
</div>
</div>
{photoError && (
<p role="alert" className="error-message">
{t(photoError)}
</p>
)}
{photoSaved && (
<p role="status" className="success-message">
{t("Profile photo saved.")}
</p>
)}
<label className="field">
{t("Display name")}
<input
name="displayName"
autoComplete="nickname"
value={name}
onChange={(e) => setName(e.target.value)}
minLength={2}
maxLength={32}
required
disabled={busy}
/>
<small>
{t("2–32 characters. Display names do not have to be unique.")}
</small>
</label>
<div className="account-email">
<Mail size={17} />
<div>
<span>{t("Email address")}</span>
<strong>{email}</strong>
<small>
{t("Used for sign-in and recovery. Only you can see it.")}
</small>
</div>
</div>
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{t(message)}
</p>
)}
<button className="button primary" disabled={busy}>
{busy ? t("Saving…") : t("Save display name")}
</button>
</form>
</section>
<section className="settings-card">
<h2>{t("MineGeo username")}</h2>
<p className="muted">{t("Your unique username for applications. You can change it anytime.")}</p>
<form onSubmit={saveHandle}>
<label className="field">{t("Username")}
<input value={handle} onChange={(e) => setHandle(e.target.value)} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required disabled={handleBusy} />
<small>{t("3–32 letters, numbers or underscores.")}</small>
</label>
{handleMessage && <p role="status">{t(handleMessage)}</p>}
<button className="button primary" disabled={handleBusy}>{t("Save username")}</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} />
<div>
<h2>{t("Password & security.")}</h2>
<p className="muted">{t("Keep your account yours.")}</p>
</div>
</div>
{passwordUpdated && (
<p role="status" className="success-message">
{t("Your password has been updated.")}
</p>
)}
<PasswordForm />
<div className="settings-recovery">
<h3>{t("Forgot your password?")}</h3>
<p className="muted">
{t(
"Set your first password, or recover a forgotten one, using a link sent to your account email.",
)}
</p>
<button className="button" disabled={sending} onClick={recover}>
{sending ? t("Sending…") : t("Email me a password reset link")}
</button>
{recoveryMessage && (
<p role="status" className="success-message">
{recoveryMessage}
</p>
)}
{recoveryError && (
<p role="alert" className="error-message">
{recoveryError}
</p>
)}
</div>
</section>
</div>
);
}