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

@@ -42,10 +42,14 @@ export const profileUpdateSchema = z
.object({
displayName: displayNameSchema.optional(),
glowColor: glowColorSchema.optional(),
avatarPath: z.string().max(128).nullable().optional(),
})
.strict()
.refine(
(value) => value.displayName !== undefined || value.glowColor !== undefined,
(value) =>
value.displayName !== undefined ||
value.glowColor !== undefined ||
value.avatarPath !== undefined,
"Choose a profile change",
);
export const initialPasswordSchema = z

View File

@@ -151,6 +151,7 @@ export type Database = {
revision_id: string;
host_player_id: string;
difficulty: string;
difficulty_settings: Json;
player_limit: number;
status: string;
current_round: number;
@@ -162,6 +163,7 @@ export type Database = {
revision_id: string;
host_player_id: string;
difficulty: string;
difficulty_settings: Json;
player_limit: number;
status?: string;
current_round?: number;
@@ -176,6 +178,28 @@ export type Database = {
};
Relationships: [];
};
difficulty_presets: {
Row: {
created_at: string;
id: string;
name: string;
seconds: number;
zoom: boolean;
};
Insert: {
created_at?: string;
id?: string;
name: string;
seconds: number;
zoom: boolean;
};
Update: {
name?: string;
seconds?: number;
zoom?: boolean;
};
Relationships: [];
};
multiplayer_rounds: {
Row: {
room_id: string;
@@ -476,6 +500,7 @@ export type Database = {
created_at: string;
current_round: number;
difficulty: string;
difficulty_settings: Json;
id: string;
owner_id: string | null;
revision_id: string;
@@ -485,6 +510,7 @@ export type Database = {
created_at?: string;
current_round?: number;
difficulty: string;
difficulty_settings: Json;
id?: string;
owner_id?: string | null;
revision_id: string;
@@ -494,6 +520,7 @@ export type Database = {
created_at?: string;
current_round?: number;
difficulty?: string;
difficulty_settings?: Json;
id?: string;
owner_id?: string | null;
revision_id?: string;
@@ -540,6 +567,7 @@ export type Database = {
};
profiles: {
Row: AccountStatusFields & {
avatar_path: string | null;
glow_color: string;
bug_finder: boolean;
custom_prefix: string | null;
@@ -551,6 +579,7 @@ export type Database = {
role: string;
};
Insert: Partial<AccountStatusFields> & {
avatar_path?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -562,6 +591,7 @@ export type Database = {
role?: string;
};
Update: Partial<AccountStatusFields> & {
avatar_path?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;

View File

@@ -169,6 +169,16 @@
"← My account": "← Мой аккаунт",
"Your public profile.": "Твой публичный профиль.",
"A name for the maps and places you share.": "Имя для карт и мест, которыми ты делишься.",
"Profile photo": "Фото профиля",
"JPG, PNG or WebP · Maximum size: 5 MB.": "JPG, PNG или WebP · Максимальный размер: 5 МБ.",
"Images must be 5 MB or smaller.": "Изображение должно быть не больше 5 МБ.",
"Choose a JPG, PNG or WebP image.": "Выбери изображение JPG, PNG или WebP.",
"Could not upload your profile photo. Please try again.": "Не удалось загрузить фото профиля. Попробуй ещё раз.",
"Upload your profile photo again": "Загрузи фото профиля ещё раз",
"Choose a valid profile photo": "Выбери подходящее фото профиля",
"Choose an image no larger than 5 MB": "Выбери изображение размером не больше 5 МБ",
"Remove photo": "Удалить фото",
"Profile photo saved.": "Фото профиля сохранено.",
"2–32 characters. Display names do not have to be unique.": "От 2 до 32 символов. Имя не обязано быть уникальным.",
"Used for sign-in and recovery. Only you can see it.": "Для входа и восстановления. Почту видишь только ты.",
"Save display name": "Сохранить имя",
@@ -230,6 +240,13 @@
"Terrain of {name}": "Рельеф карты {name}",
"YOUR EXPEDITION": "ТВОЯ ЭКСПЕДИЦИЯ",
"Choose your challenge.": "Выбери сложность.",
"Difficulty": "Сложность",
"Create custom difficulty": "Создать свою сложность",
"Difficulty name": "Название сложности",
"Name this difficulty": "Например, «Хардкор»",
"Round time": "Время на раунд",
"Allow zoom": "Разрешить приближение",
"Save difficulty": "Сохранить сложность",
"s per round": "с на раунд",
"Zoom allowed": "Можно приближать",
"No zoom": "Без приближения",

View File

@@ -1,11 +1,14 @@
import { z } from "zod";
import { displayNameSchema } from "./account";
import type { Bounds, Difficulty } from "./protocol";
import type { Bounds, DifficultyPreset } from "./protocol";
export const createRoomSchema = z
.object({
datasetId: z.string().uuid(),
difficulty: z.enum(["easy", "normal", "hard"]),
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
z.string().uuid(),
]),
playerLimit: z.number().int().min(2).max(16),
})
.strict();
@@ -53,7 +56,8 @@ export type RoomView = {
id: string;
datasetId: string;
name: string;
difficulty: Difficulty;
difficulty: string;
difficultySettings: DifficultyPreset;
playerLimit: number;
playerCount: number;
status: "lobby" | "loading" | "playing" | "results" | "finished" | "closed";

View File

@@ -1,5 +1,12 @@
import { z } from "zod";
export const defaultGlowColor = "#ffe6a1";
export const profileAvatarBucket = "minegeo-avatars";
export const profileAvatarMaxBytes = 5 * 1024 * 1024;
export const profileAvatarMimeTypes = [
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const glowColorSchema = z
.string()
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
@@ -28,6 +35,7 @@ export const prefixUpdateSchema = z
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
.strict();
export type ProfileAppearance = {
avatar_path?: string | null;
name_glow?: boolean;
glow_color?: string;
role?: string;
@@ -36,7 +44,7 @@ export type ProfileAppearance = {
custom_prefix_icon?: string;
};
export const appearanceColumns =
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
"avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
export type BadgeUser = ProfileAppearance & {
id: string;
display_name: string;
@@ -44,3 +52,10 @@ export type BadgeUser = ProfileAppearance & {
export function glowColor(value?: string) {
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
}
export function profileAvatarUrl(path?: string | null) {
const base = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, "");
if (!base || !path) return null;
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
return `${base}/storage/v1/object/public/${profileAvatarBucket}/${encodedPath}`;
}

View File

@@ -171,6 +171,35 @@ export const difficulties = {
hard: { seconds: 30, zoom: false, label: "Hard" },
} as const;
export type Difficulty = keyof typeof difficulties;
export type DifficultyPreset = {
id: string;
name: string;
seconds: number;
zoom: boolean;
};
export const customDifficultySchema = z
.object({
name: z
.string()
.trim()
.min(2)
.max(40)
.refine(
(name) =>
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
name,
),
"Difficulty names cannot contain hidden controls",
),
seconds: z
.number()
.int()
.min(15)
.max(600)
.refine((seconds) => seconds % 15 === 0),
zoom: z.boolean(),
})
.strict();
export function scoreGuess(
a: Pick<Point, "x" | "z">,
b: Pick<Point, "x" | "z">,