Add profile photo uploads and custom difficulty support
This commit is contained in:
@@ -4,22 +4,31 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { api } from "./StartGame";
|
||||
import { PasswordForm } from "./PasswordForm";
|
||||
import { displayNameSchema } from "@/shared/account";
|
||||
import {
|
||||
defaultGlowColor,
|
||||
profileAvatarBucket,
|
||||
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;
|
||||
}) {
|
||||
@@ -36,6 +45,80 @@ export function AccountSettings({
|
||||
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;
|
||||
}
|
||||
const extension =
|
||||
file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/png"
|
||||
? "png"
|
||||
: "webp";
|
||||
const path = `${accountId}/${crypto.randomUUID()}.${extension}`;
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
setPhotoBusy(true);
|
||||
let uploaded = false;
|
||||
try {
|
||||
const { error: uploadError } = await client.storage
|
||||
.from(profileAvatarBucket)
|
||||
.upload(path, file, {
|
||||
cacheControl: "31536000",
|
||||
contentType: file.type,
|
||||
upsert: false,
|
||||
});
|
||||
if (uploadError)
|
||||
throw new Error("Could not upload your profile photo. Please try again.");
|
||||
uploaded = true;
|
||||
await api("profile", { avatarPath: path }, "PATCH");
|
||||
setPhotoPath(path);
|
||||
setPhotoSaved(true);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (uploaded)
|
||||
await client.storage.from(profileAvatarBucket).remove([path]);
|
||||
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);
|
||||
@@ -104,6 +187,45 @@ export function AccountSettings({
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user