Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m16s
331 lines
9.9 KiB
TypeScript
331 lines
9.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|