Add profile photo uploads and custom difficulty support

This commit is contained in:
2026-10-13 20:50:54 +05:00
parent 8c3de302e7
commit de812453ec
37 changed files with 1139 additions and 74 deletions

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>