From de812453ecf4b2b759b117e52ea3d9bdb8be662d Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Tue, 13 Oct 2026 20:50:54 +0500 Subject: [PATCH] Add profile photo uploads and custom difficulty support --- .dockerignore | 19 + Dockerfile | 52 +++ app/account.css | 23 ++ app/dashboard/page.tsx | 14 +- app/globals.css | 47 ++- app/leaderboard.css | 2 + app/multiplayer.css | 15 + app/settings/page.tsx | 2 + components/AccountSettings.tsx | 122 ++++++ components/Game.tsx | 13 +- components/Leaderboard.tsx | 10 +- components/MultiplayerGame.tsx | 9 +- components/MultiplayerRoom.tsx | 24 ++ components/PixelArt.tsx | 26 +- components/Results.tsx | 5 +- components/StartGame.tsx | 147 ++++++- docs/multiplayer.md | 9 + docs/vercel-deployment.md | 10 + lib/account-api.ts | 54 ++- lib/api.ts | 38 +- lib/catalog.ts | 4 +- lib/discord-announcements.ts | 11 +- lib/leaderboard.ts | 2 +- lib/multiplayer-appearance.ts | 2 +- lib/profile.ts | 2 +- next.config.ts | 6 + package-lock.json | 33 +- package.json | 2 +- scripts/deploy-vercel.mjs | 6 +- shared/account.ts | 6 +- shared/database.ts | 30 ++ shared/locales/ru.json | 17 + shared/multiplayer.ts | 10 +- shared/profile-appearance.ts | 17 +- shared/protocol.ts | 29 ++ ...261013095201_custom_difficulty_presets.sql | 361 ++++++++++++++++++ .../20261013123545_profile_avatars.sql | 34 ++ 37 files changed, 1139 insertions(+), 74 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 supabase/migrations/20261013095201_custom_difficulty_presets.sql create mode 100644 supabase/migrations/20261013123545_profile_avatars.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27e8430 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +**/.git +node_modules +**/node_modules +.next +**/.next +.env* +**/.env* +.cache +**/.cache +.data +artifacts +coverage +playwright-report +test-results +*.log +*.hprof +desktop/dist +public/generated diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8a725b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS dependencies +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM dependencies AS builder +COPY . . +# Next.js embeds NEXT_PUBLIC_* values into browser bundles; the server secret is runtime-only. +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + NODE_OPTIONS=--max-old-space-size=640 \ + NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \ + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY} +RUN if [ -z "$NEXT_PUBLIC_SUPABASE_URL" ] || [ -z "$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ]; then \ + echo "Pass NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY as Docker build arguments." >&2; \ + exit 1; \ + fi && npm run build + +FROM node:22-bookworm-slim AS production-dependencies +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + NODE_OPTIONS=--max-old-space-size=256 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 \ + MINEGEO_HOST=0.0.0.0 \ + MINEGEO_WORKER_HEAP_MB=512 \ + UV_THREADPOOL_SIZE=2 + +COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/.next ./.next +COPY --from=builder --chown=node:node /app/public ./public +COPY --chown=node:node package.json tsconfig.json next.config.ts ./ +COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs ./scripts/ +COPY --chown=node:node worker ./worker +COPY --chown=node:node shared ./shared +COPY --chown=node:node lib ./lib +COPY --chown=node:node renderer ./renderer +RUN mkdir -p /app/.cache && chown node:node /app/.cache + +USER node +EXPOSE 3000 +CMD ["node", "scripts/supervise.mjs", "production"] diff --git a/app/account.css b/app/account.css index 290465f..c4f2b9f 100644 --- a/app/account.css +++ b/app/account.css @@ -177,6 +177,29 @@ box-shadow: 0 12px 30px #0002; min-width: 0; } +.account-avatar-control { + display: flex; + align-items: center; + gap: 18px; + margin: 24px 0; +} +.account-avatar-preview { + width: 84px; + height: 84px; + object-fit: cover; + box-shadow: 3px 3px #03070b; +} +.account-avatar-input { + flex: 1; + min-width: 0; +} +.account-avatar-input .field { + margin: 0 0 12px; +} +.account-avatar-input .field input { + min-height: 40px; + padding: 8px; +} .settings-heading { display: flex; align-items: flex-start; diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 3831959..74164db 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -18,7 +18,7 @@ export default async function DashboardPage() { ? required( await admin() .from("game_sessions") - .select("id,difficulty,created_at,current_round") + .select("id,difficulty,difficulty_settings,created_at,current_round") .eq("owner_id", account.id) .gte("created_at", historyCutoff) .order("created_at", { ascending: false }) @@ -52,7 +52,17 @@ export default async function DashboardPage() {
{formatDate(s.created_at)} ·{" "} - {t(s.difficulty[0].toUpperCase() + s.difficulty.slice(1))}{" "} + {s.difficulty_settings && + typeof s.difficulty_settings === "object" && + !Array.isArray(s.difficulty_settings) && + typeof s.difficulty_settings.name === "string" + ? ["easy", "normal", "hard"].includes(s.difficulty) + ? t(s.difficulty_settings.name) + : s.difficulty_settings.name + : t( + s.difficulty[0].toUpperCase() + + s.difficulty.slice(1), + )}{" "} · {s.current_round} {t("/5 rounds")} diff --git a/app/globals.css b/app/globals.css index 5732a7f..fc3c824 100644 --- a/app/globals.css +++ b/app/globals.css @@ -801,7 +801,7 @@ nav a:hover, } .difficulty-options { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 25px 0; } @@ -829,6 +829,51 @@ nav a:hover, font-size: 10px; margin-top: 5px; } +.difficulty-library { + margin: -12px 0 24px; +} +.difficulty-builder { + display: grid; + gap: 10px; + max-width: 430px; + margin-top: 12px; + padding: 18px; + border: 1px solid var(--line); + background: #0d160f; +} +.difficulty-builder label { + color: var(--muted); + font-size: 12px; +} +.difficulty-builder > input, +.difficulty-builder-time input { + min-height: 42px; + padding: 9px 11px; + border: 1px solid #34452e; + background: var(--panel); + color: var(--text); + font: inherit; +} +.difficulty-builder-time { + display: flex; + align-items: center; + gap: 9px; +} +.difficulty-builder-time input { + width: 120px; +} +.difficulty-builder-time span { + color: var(--muted); + font-size: 12px; +} +.difficulty-builder .difficulty-builder-zoom { + display: flex; + align-items: center; + gap: 9px; +} +.difficulty-builder-zoom input { + accent-color: var(--green); +} .prose { max-width: 850px; } diff --git a/app/leaderboard.css b/app/leaderboard.css index 3178f09..314b2ff 100644 --- a/app/leaderboard.css +++ b/app/leaderboard.css @@ -238,6 +238,8 @@ input:focus-visible { width: 36px; height: 36px; flex-shrink: 0; + display: block; + object-fit: cover; background: #314034; box-shadow: 2px 2px #03070b; } diff --git a/app/multiplayer.css b/app/multiplayer.css index 27631bd..616a1fb 100644 --- a/app/multiplayer.css +++ b/app/multiplayer.css @@ -83,6 +83,10 @@ gap: 8px; flex-wrap: wrap; } +.multiplayer-avatar { + width: 28px; + height: 28px; +} .multiplayer-table-wrap { overflow-x: auto; margin: 28px 0; @@ -99,6 +103,13 @@ .multiplayer-final .multiplayer-you { background: #8dce6614; } +.multiplayer-final-avatar { + width: 24px; + height: 24px; + display: inline-block; + vertical-align: middle; + margin-right: 6px; +} .multiplayer-scoreboard { position: absolute; z-index: 20; @@ -139,6 +150,10 @@ flex: 1; min-width: 0; } +.multiplayer-scoreboard .multiplayer-avatar { + width: 26px; + height: 26px; +} .multiplayer-scoreboard li small { display: block; font-size: 11px; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d562f74..432797e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -82,8 +82,10 @@ export default async function Settings({ {active && ( diff --git a/components/AccountSettings.tsx b/components/AccountSettings.tsx index 9e784fa..8055898 100644 --- a/components/AccountSettings.tsx +++ b/components/AccountSettings.tsx @@ -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) { + 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({
+
+ +
+ + {photoPath && ( + + )} +
+
+ {photoError && ( +

+ {t(photoError)} +

+ )} + {photoSaved && ( +

+ {t("Profile photo saved.")} +

+ )}