Files
MineGeo/components/AccountSettings.tsx
pavel444-byte 0e4412c99b
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
Revert profile cosmetics and GeoCoin currency
2026-10-15 18:32:18 +05:00

331 lines
9.9 KiB
TypeScript
Raw 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,
avatarPath,
appearance,
passwordUpdated = false,
}: {
accountId: string;
email: string;
displayName: string;
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 [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 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>
);
}