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

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