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
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Brand } from "./Brand";
|
||||
import { Panorama } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||
import type { DifficultyPreset, Bounds } from "@/shared/protocol";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import { ReportContent } from "./ReportContent";
|
||||
export type RoundResult = {
|
||||
@@ -28,7 +28,8 @@ export type RoundResult = {
|
||||
export type SessionView = {
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: Difficulty;
|
||||
difficulty: string;
|
||||
difficultySettings: DifficultyPreset;
|
||||
round: number;
|
||||
bounds: Bounds;
|
||||
map: string;
|
||||
@@ -74,7 +75,7 @@ export function Game({ id }: { id: string }) {
|
||||
s.rounds[s.round].deadline!,
|
||||
performance.now(),
|
||||
)
|
||||
: difficulties[s.difficulty].seconds,
|
||||
: s.difficultySettings.seconds,
|
||||
);
|
||||
} catch (e) {
|
||||
if (version === loadVersion.current) setError((e as Error).message);
|
||||
@@ -196,7 +197,7 @@ export function Game({ id }: { id: string }) {
|
||||
<Panorama
|
||||
key={session.round}
|
||||
urls={current.faces}
|
||||
zoom={difficulties[session.difficulty].zoom}
|
||||
zoom={session.difficultySettings.zoom}
|
||||
onReady={ready}
|
||||
onError={onError}
|
||||
/>
|
||||
@@ -289,7 +290,9 @@ export function Game({ id }: { id: string }) {
|
||||
</span>
|
||||
<span>
|
||||
<MousePointer2 size={12} />{" "}
|
||||
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
|
||||
{session && !session.difficultySettings.zoom
|
||||
? t("NO ZOOM")
|
||||
: t("SCROLL TO ZOOM")}
|
||||
</span>
|
||||
</div>
|
||||
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
||||
|
||||
@@ -104,7 +104,10 @@ export function LeaderboardTable({
|
||||
</td>
|
||||
<th scope="row">
|
||||
<div className="leaderboard-player">
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
/>
|
||||
<span className="player-name">
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
{entry.player_id === playerId && (
|
||||
@@ -152,7 +155,10 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
||||
place: String(index + 1).padStart(2, "0"),
|
||||
})}
|
||||
</span>
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
/>
|
||||
<h2>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { Username } from "./Username";
|
||||
import { PlayerAvatar } from "./PixelArt";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -15,7 +16,6 @@ import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { ReportContent } from "./ReportContent";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
import type { RoomView, RoomMedia } from "@/shared/multiplayer";
|
||||
|
||||
export function MultiplayerGame({
|
||||
@@ -101,7 +101,7 @@ export function MultiplayerGame({
|
||||
{media && (
|
||||
<Panorama
|
||||
urls={media.faces}
|
||||
zoom={difficulties[room.difficulty].zoom}
|
||||
zoom={room.difficultySettings.zoom}
|
||||
onReady={panoramaReady}
|
||||
onError={mediaError}
|
||||
/>
|
||||
@@ -139,6 +139,11 @@ export function MultiplayerGame({
|
||||
<ul>
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-avatar"
|
||||
/>
|
||||
<span>
|
||||
<Username name={player.name} appearance={player.appearance} />
|
||||
{player.id === room.you && <small> {t("(you)")}</small>}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { api } from "@/lib/client-api";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import type { RoomView } from "@/shared/multiplayer";
|
||||
import { Username } from "./Username";
|
||||
import { PlayerAvatar } from "./PixelArt";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
|
||||
export function MultiplayerRoom({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
@@ -181,6 +183,18 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
<span className="eyebrow">
|
||||
<Users size={16} /> {t("Multiplayer")} · {room.name}
|
||||
</span>
|
||||
<p className="muted">
|
||||
{t("Difficulty")}: <strong>
|
||||
{room.difficultySettings.id in difficulties
|
||||
? t(room.difficultySettings.name)
|
||||
: room.difficultySettings.name}
|
||||
</strong>
|
||||
{" · "}
|
||||
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
|
||||
{room.difficultySettings.zoom
|
||||
? t("Zoom allowed")
|
||||
: t("No zoom")}
|
||||
</p>
|
||||
<h1>
|
||||
{finished
|
||||
? t("Final standings")
|
||||
@@ -317,6 +331,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<span>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-avatar"
|
||||
/>
|
||||
{player.id === room.hostId && (
|
||||
<Crown size={17} aria-label={t("Room owner")} />
|
||||
)}{" "}
|
||||
@@ -419,6 +438,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
>
|
||||
<td>{player.rank}</td>
|
||||
<td>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-final-avatar"
|
||||
/>
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { profileAvatarUrl } from "@/shared/profile-appearance";
|
||||
|
||||
export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -22,7 +24,27 @@ export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||
}
|
||||
|
||||
// Generated markers, not player skins. No third-party avatar requests.
|
||||
export function PlayerAvatar({ id }: { id: string }) {
|
||||
export function PlayerAvatar({
|
||||
id,
|
||||
avatarPath,
|
||||
className = "",
|
||||
}: {
|
||||
id: string;
|
||||
avatarPath?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const avatarUrl = profileAvatarUrl(avatarPath);
|
||||
if (avatarUrl)
|
||||
return (
|
||||
<img
|
||||
className={`player-avatar ${className}`.trim()}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
const seed = [...id].reduce(
|
||||
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
|
||||
0,
|
||||
@@ -32,7 +54,7 @@ export function PlayerAvatar({ id }: { id: string }) {
|
||||
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
|
||||
return (
|
||||
<svg
|
||||
className="player-avatar"
|
||||
className={`player-avatar ${className}`.trim()}
|
||||
viewBox="0 0 8 8"
|
||||
aria-hidden="true"
|
||||
shapeRendering="crispEdges"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { api } from "./StartGame";
|
||||
import { GuessMap } from "./GuessMap";
|
||||
import type { SessionView } from "./Game";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
export function Results({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
const [session, setSession] = useState<SessionView | null>(null),
|
||||
@@ -40,7 +41,9 @@ export function Results({ id }: { id: string }) {
|
||||
<>
|
||||
<span className="eyebrow">
|
||||
{t("EXPEDITION COMPLETE ·")}{" "}
|
||||
{t(session.difficulty[0].toUpperCase() + session.difficulty.slice(1))}
|
||||
{session.difficultySettings.id in difficulties
|
||||
? t(session.difficultySettings.name)
|
||||
: session.difficultySettings.name}
|
||||
</span>
|
||||
<h1>{t("Every guess is a story.")}</h1>
|
||||
<p className="lead">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||
import {
|
||||
difficulties,
|
||||
type DifficultyPreset,
|
||||
} from "@/shared/protocol";
|
||||
import { api } from "@/lib/client-api";
|
||||
export { api } from "@/lib/client-api";
|
||||
export function StartGame({
|
||||
@@ -16,13 +19,65 @@ export function StartGame({
|
||||
hostName: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||
const [difficulty, setDifficulty] = useState("normal"),
|
||||
[customPresets, setCustomPresets] = useState<DifficultyPreset[]>([]),
|
||||
[showDifficultyForm, setShowDifficultyForm] = useState(false),
|
||||
[difficultyName, setDifficultyName] = useState(""),
|
||||
[difficultySeconds, setDifficultySeconds] = useState(90),
|
||||
[difficultyZoom, setDifficultyZoom] = useState(true),
|
||||
[savingDifficulty, setSavingDifficulty] = useState(false),
|
||||
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
|
||||
[playerLimit, setPlayerLimit] = useState(4),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const starting = useRef(false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
|
||||
.then(({ presets }) => {
|
||||
if (!disposed) setCustomPresets(presets);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
const options: DifficultyPreset[] = [
|
||||
...Object.entries(difficulties).map(([id, preset]) => ({
|
||||
id,
|
||||
name: preset.label,
|
||||
seconds: preset.seconds,
|
||||
zoom: preset.zoom,
|
||||
})),
|
||||
...customPresets,
|
||||
];
|
||||
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (savingDifficulty) return;
|
||||
setSavingDifficulty(true);
|
||||
setError("");
|
||||
try {
|
||||
const { preset } = await api<{ preset: DifficultyPreset }>(
|
||||
"difficulty-presets",
|
||||
{
|
||||
name: difficultyName.trim(),
|
||||
seconds: difficultySeconds,
|
||||
zoom: difficultyZoom,
|
||||
},
|
||||
);
|
||||
setCustomPresets((current) => [...current, preset]);
|
||||
setDifficulty(preset.id);
|
||||
setDifficultyName("");
|
||||
setDifficultySeconds(90);
|
||||
setDifficultyZoom(true);
|
||||
setShowDifficultyForm(false);
|
||||
} catch (failure) {
|
||||
setError((failure as Error).message);
|
||||
} finally {
|
||||
setSavingDifficulty(false);
|
||||
}
|
||||
}
|
||||
async function start() {
|
||||
if (starting.current) return;
|
||||
starting.current = true;
|
||||
@@ -67,27 +122,97 @@ export function StartGame({
|
||||
</button>
|
||||
</div>
|
||||
<div className="difficulty-options">
|
||||
{Object.entries(difficulties).map(([key, value]) => (
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={key}
|
||||
aria-pressed={difficulty === key}
|
||||
className={difficulty === key ? "selected" : ""}
|
||||
key={option.id}
|
||||
aria-pressed={difficulty === option.id}
|
||||
className={difficulty === option.id ? "selected" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setDifficulty(key as Difficulty)}
|
||||
onClick={() => setDifficulty(option.id)}
|
||||
>
|
||||
<strong>{t(value.label)}</strong>
|
||||
<strong>
|
||||
{option.id in difficulties ? t(option.name) : option.name}
|
||||
</strong>
|
||||
<span>
|
||||
<Timer size={13} />
|
||||
{value.seconds}
|
||||
{option.seconds}
|
||||
{t("s per round")}
|
||||
</span>
|
||||
<span>
|
||||
<ZoomIn size={13} />
|
||||
{value.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||
{option.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="difficulty-library">
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
aria-expanded={showDifficultyForm}
|
||||
onClick={() => {
|
||||
setShowDifficultyForm((visible) => !visible);
|
||||
setError("");
|
||||
}}
|
||||
disabled={loading || savingDifficulty}
|
||||
>
|
||||
{showDifficultyForm
|
||||
? t("Cancel")
|
||||
: "+ " + t("Create custom difficulty")}
|
||||
</button>
|
||||
{showDifficultyForm && (
|
||||
<form className="difficulty-builder" onSubmit={saveDifficulty}>
|
||||
<label htmlFor="custom-difficulty-name">
|
||||
{t("Difficulty name")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-difficulty-name"
|
||||
value={difficultyName}
|
||||
onChange={(event) => setDifficultyName(event.target.value)}
|
||||
minLength={2}
|
||||
maxLength={40}
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={t("Name this difficulty")}
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
<label htmlFor="custom-difficulty-seconds">
|
||||
{t("Round time")}
|
||||
</label>
|
||||
<div className="difficulty-builder-time">
|
||||
<input
|
||||
id="custom-difficulty-seconds"
|
||||
type="number"
|
||||
min={15}
|
||||
max={600}
|
||||
step={15}
|
||||
value={difficultySeconds}
|
||||
onChange={(event) =>
|
||||
setDifficultySeconds(Number(event.target.value))
|
||||
}
|
||||
required
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
<span>{t("s per round")}</span>
|
||||
</div>
|
||||
<label className="difficulty-builder-zoom">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={difficultyZoom}
|
||||
onChange={(event) => setDifficultyZoom(event.target.checked)}
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
{t("Allow zoom")}
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={savingDifficulty || loading}
|
||||
>
|
||||
{savingDifficulty ? t("Saving…") : t("Save difficulty")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted">
|
||||
{t("5 rounds · 25,000 possible points · No movement")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user