From f12cb134aa787f7bd34fc8c8d0a0dce2b95b057b Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 00:05:29 -0700 Subject: [PATCH 1/6] Add Docker BuildKit layer caching to all CI builds Uses registry-backed cache (Docker Hub) so npm ci and unchanged build layers are reused across runs. Separate cache tags per environment to avoid cross-contamination. --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84e0637..38d08912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: with: context: ./backend push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-backend:buildcache + cache-to: type=registry,ref=ptrlrd/spire-codex-backend:buildcache,mode=max tags: | ptrlrd/spire-codex-backend:latest ptrlrd/spire-codex-backend:${{ github.sha }} @@ -109,6 +111,8 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache + cache-to: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache,mode=max build-args: | NEXT_PUBLIC_API_URL= NEXT_PUBLIC_SITE_URL=https://spire-codex.com @@ -140,6 +144,8 @@ jobs: with: context: ./backend push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-backend:buildcache-beta + cache-to: type=registry,ref=ptrlrd/spire-codex-backend:buildcache-beta,mode=max tags: | ptrlrd/spire-codex-backend:beta ptrlrd/spire-codex-backend:beta-${{ github.sha }} @@ -150,6 +156,8 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache-beta + cache-to: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache-beta,mode=max build-args: | NEXT_PUBLIC_API_URL= NEXT_PUBLIC_SITE_URL=https://beta.spire-codex.com @@ -181,6 +189,8 @@ jobs: with: context: ./backend push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-backend:buildcache-staging + cache-to: type=registry,ref=ptrlrd/spire-codex-backend:buildcache-staging,mode=max tags: | ptrlrd/spire-codex-backend:staging ptrlrd/spire-codex-backend:staging-${{ github.sha }} @@ -191,6 +201,8 @@ jobs: context: ./frontend file: ./frontend/Dockerfile push: true + cache-from: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache-staging + cache-to: type=registry,ref=ptrlrd/spire-codex-frontend:buildcache-staging,mode=max build-args: | NEXT_PUBLIC_API_URL= NEXT_PUBLIC_SITE_URL=https://staging.spire-codex.com From b23ca68956cb4c39bfc691e821cb8dd3576877f4 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 00:13:43 -0700 Subject: [PATCH 2/6] Use .png extension for CDN image URLs R2 bucket has PNGs, not webp. CDN paths now request .png; non-CDN paths still use .webp (Docker build converts those). --- backend/app/parsers/parser_paths.py | 6 +++--- frontend/lib/image-url.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/parsers/parser_paths.py b/backend/app/parsers/parser_paths.py index c5220a05..ac69a557 100644 --- a/backend/app/parsers/parser_paths.py +++ b/backend/app/parsers/parser_paths.py @@ -106,7 +106,7 @@ def resolve_image_url(entity_type: str, name_stem: str) -> str | None: if per_version_png.exists(): if CDN_BASE_URL: return ( - f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" + f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.png" ) return f"/static/images/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" @@ -114,14 +114,14 @@ def resolve_image_url(entity_type: str, name_stem: str) -> str | None: if legacy_beta_png.exists(): if CDN_BASE_URL: return ( - f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" + f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.png" ) return f"/static/data-beta/{BETA_VERSION}/images/{entity_type}/{name_stem}.webp" stable_png = STATIC_IMAGES_DIR / entity_type / f"{name_stem}.png" if stable_png.exists(): if CDN_BASE_URL: - return f"{CDN_BASE_URL}/{entity_type}/{name_stem}.webp" + return f"{CDN_BASE_URL}/{entity_type}/{name_stem}.png" return f"/static/images/{entity_type}/{name_stem}.webp" return None diff --git a/frontend/lib/image-url.ts b/frontend/lib/image-url.ts index bd2db414..3aa2816e 100644 --- a/frontend/lib/image-url.ts +++ b/frontend/lib/image-url.ts @@ -5,7 +5,7 @@ export function imageUrl(path: string | null | undefined): string { if (!path) return ""; if (path.startsWith("http")) return path; if (CDN_URL && path.startsWith("/static/images/")) { - return `${CDN_URL}${path.replace("/static/images/", "/")}`; + return `${CDN_URL}${path.replace("/static/images/", "/").replace(/\.webp$/, ".png")}`; } return `${API}${path}`; } From d23a01e42341b8478c807fec19c0520f53f5038e Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 09:02:56 -0700 Subject: [PATCH 3/6] Revert to .webp for CDN URLs R2 bucket should have webp files, not PNGs. --- backend/app/parsers/parser_paths.py | 6 +++--- frontend/lib/image-url.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/parsers/parser_paths.py b/backend/app/parsers/parser_paths.py index ac69a557..c5220a05 100644 --- a/backend/app/parsers/parser_paths.py +++ b/backend/app/parsers/parser_paths.py @@ -106,7 +106,7 @@ def resolve_image_url(entity_type: str, name_stem: str) -> str | None: if per_version_png.exists(): if CDN_BASE_URL: return ( - f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.png" + f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" ) return f"/static/images/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" @@ -114,14 +114,14 @@ def resolve_image_url(entity_type: str, name_stem: str) -> str | None: if legacy_beta_png.exists(): if CDN_BASE_URL: return ( - f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.png" + f"{CDN_BASE_URL}/beta/{BETA_VERSION}/{entity_type}/{name_stem}.webp" ) return f"/static/data-beta/{BETA_VERSION}/images/{entity_type}/{name_stem}.webp" stable_png = STATIC_IMAGES_DIR / entity_type / f"{name_stem}.png" if stable_png.exists(): if CDN_BASE_URL: - return f"{CDN_BASE_URL}/{entity_type}/{name_stem}.png" + return f"{CDN_BASE_URL}/{entity_type}/{name_stem}.webp" return f"/static/images/{entity_type}/{name_stem}.webp" return None diff --git a/frontend/lib/image-url.ts b/frontend/lib/image-url.ts index 3aa2816e..bd2db414 100644 --- a/frontend/lib/image-url.ts +++ b/frontend/lib/image-url.ts @@ -5,7 +5,7 @@ export function imageUrl(path: string | null | undefined): string { if (!path) return ""; if (path.startsWith("http")) return path; if (CDN_URL && path.startsWith("/static/images/")) { - return `${CDN_URL}${path.replace("/static/images/", "/").replace(/\.webp$/, ".png")}`; + return `${CDN_URL}${path.replace("/static/images/", "/")}`; } return `${API}${path}`; } From 2d8c435d584b1f795ff15eb7ebf40ccd31c26c7d Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 21:34:55 -0700 Subject: [PATCH 4/6] Add Overwolf and Choose Files buttons below drop zone text Overwolf download and Choose Files buttons sit side by side directly under the drag-and-drop text. OS paths remain below the hr separator. --- frontend/app/components/RunFileHelp.tsx | 13 +-------- .../leaderboards/submit/SubmitRunClient.tsx | 28 ++++++++++++++++++- frontend/app/profile/ProfileClient.tsx | 10 ++++++- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/frontend/app/components/RunFileHelp.tsx b/frontend/app/components/RunFileHelp.tsx index f2cd4997..39e54275 100644 --- a/frontend/app/components/RunFileHelp.tsx +++ b/frontend/app/components/RunFileHelp.tsx @@ -7,18 +7,7 @@ export default function RunFileHelp() { const { lang } = useLanguage(); return ( -
e.stopPropagation()}> - - +
e.stopPropagation()}>

{t("Your .run files live here:", lang)} diff --git a/frontend/app/leaderboards/submit/SubmitRunClient.tsx b/frontend/app/leaderboards/submit/SubmitRunClient.tsx index f6abc05d..92627a68 100644 --- a/frontend/app/leaderboards/submit/SubmitRunClient.tsx +++ b/frontend/app/leaderboards/submit/SubmitRunClient.tsx @@ -392,11 +392,37 @@ export default function SubmitRunClient() { {t("Drop files here...", lang)}

) : ( -

+

Drop .run files here or click to browse

)} + {!isDragging && ( +
e.stopPropagation()}> + + Download Overwolf Companion App + + +
+ )} + {uploadProgress && (
diff --git a/frontend/app/profile/ProfileClient.tsx b/frontend/app/profile/ProfileClient.tsx index bd8ad119..a246233c 100644 --- a/frontend/app/profile/ProfileClient.tsx +++ b/frontend/app/profile/ProfileClient.tsx @@ -200,7 +200,15 @@ export default function ProfileClient() { )}
-
+ From 227cf5e8e04c0fd951e506982eebe1a4ac3419c3 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 21:52:50 -0700 Subject: [PATCH 5/6] Extract RunDropZone as shared component for submit and profile pages Both pages now use the same drop zone with Overwolf + Choose Files buttons, progress bar, and OS file paths. 1:1 identical UI. --- frontend/app/components/RunDropZone.tsx | 153 ++++++++++++++++ .../leaderboards/submit/SubmitRunClient.tsx | 170 +----------------- frontend/app/profile/ProfileClient.tsx | 52 +----- 3 files changed, 157 insertions(+), 218 deletions(-) create mode 100644 frontend/app/components/RunDropZone.tsx diff --git a/frontend/app/components/RunDropZone.tsx b/frontend/app/components/RunDropZone.tsx new file mode 100644 index 00000000..c45b0300 --- /dev/null +++ b/frontend/app/components/RunDropZone.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { useLanguage } from "@/app/contexts/LanguageContext"; +import { t } from "@/lib/ui-translations"; +import RunFileHelp from "./RunFileHelp"; + +interface UploadProgress { + total: number; + done: number; + dupes: number; + errors: number; +} + +interface RunDropZoneProps { + onFiles: (files: FileList) => void; + uploading?: boolean; + uploadProgress?: UploadProgress | null; +} + +export default function RunDropZone({ onFiles, uploading, uploadProgress }: RunDropZoneProps) { + const { lang } = useLanguage(); + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + const fileInputRef = useRef(null); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current++; + setIsDragging(true); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current--; + if (dragCounter.current === 0) setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + dragCounter.current = 0; + setIsDragging(false); + if (e.dataTransfer.files?.length) onFiles(e.dataTransfer.files); + }, [onFiles]); + + return ( +
fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center cursor-pointer transition-colors ${ + isDragging + ? "border-[var(--accent-gold)] bg-[var(--accent-gold)]/5" + : "border-[var(--border-subtle)] hover:border-[var(--border-accent)]" + }`} + > + { + if (e.target.files) onFiles(e.target.files); + e.target.value = ""; + }} + /> + + {uploading ? ( +

Uploading...

+ ) : isDragging ? ( +

+ {t("Drop files here...", lang)} +

+ ) : ( +

+ Drop .run files here or click to browse +

+ )} + + {!isDragging && !uploading && ( +
e.stopPropagation()}> + + Download Overwolf Companion App + + +
+ )} + + {uploadProgress && ( +
+
+
+
+

+ {uploadProgress.done === uploadProgress.total ? ( + <> + {t("Done!", lang)}{" "} + {uploadProgress.total - uploadProgress.dupes - uploadProgress.errors}{" "} + {t("submitted", lang)} + {uploadProgress.dupes > 0 && ( + <>, {uploadProgress.dupes} {t("duplicates skipped", lang)} + )} + {uploadProgress.errors > 0 && ( + <>, {uploadProgress.errors} {t("invalid", lang)} + )} + + ) : ( + <> + {t("Processing", lang)} {uploadProgress.done} {t("of", lang)}{" "} + {uploadProgress.total}... + + )} +

+
+ )} + +
+ +
+ ); +} diff --git a/frontend/app/leaderboards/submit/SubmitRunClient.tsx b/frontend/app/leaderboards/submit/SubmitRunClient.tsx index 92627a68..2a2db85d 100644 --- a/frontend/app/leaderboards/submit/SubmitRunClient.tsx +++ b/frontend/app/leaderboards/submit/SubmitRunClient.tsx @@ -8,7 +8,7 @@ import { useLanguage } from "@/app/contexts/LanguageContext"; import { useAuth } from "@/app/contexts/AuthContext"; import { t } from "@/lib/ui-translations"; import { IS_BETA } from "@/lib/seo"; -import RunFileHelp from "@/app/components/RunFileHelp"; +import RunDropZone from "@/app/components/RunDropZone"; const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; @@ -46,15 +46,6 @@ export default function SubmitRunClient() { dupes: number; errors: number; } | null>(null); - const [isDragging, setIsDragging] = useState(false); - const dragCounter = useRef(0); - const fileInputRef = useRef(null); - // Belt-and-suspenders against any source of double-fire on the upload - // path (the dropzone-vs-document race the native stopImmediatePropagation - // kills above, plus button double-click, retry, etc.). A ref instead of - // useState so the guard sees the latest value without waiting for a - // re-render — back-to-back drops fire the second handler before React - // commits any state from the first. const uploadInFlight = useRef(false); function isValidRunFile(data: any): boolean { @@ -219,68 +210,6 @@ export default function SubmitRunClient() { if (user) fetchRuns(); }, [user, fetchRuns]); - // Drag-and-drop handlers for the upload area - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounter.current++; - setIsDragging(true); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounter.current--; - if (dragCounter.current === 0) { - setIsDragging(false); - } - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - // The page-level `document.addEventListener("drop", …)` below also - // calls handleFileUpload so drops anywhere on the page are captured. - // React's `e.stopPropagation()` only stops further synthetic handlers - // — the underlying native event still bubbles past React's root to - // the document listener, which fires handleFileUpload a second time. - // Each file then gets POSTed twice: the first request inserts, the - // second comes back marked `duplicate`, and the UI shows "0 submitted, - // 2 skipped" for what was actually a clean two-file upload. - e.nativeEvent.stopImmediatePropagation(); - dragCounter.current = 0; - setIsDragging(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - handleFileUpload(e.dataTransfer.files); - } - }, - [handleFileUpload] - ); - - // Page-level drop handling so drops work anywhere on the page - useEffect(() => { - const handleDocDragOver = (e: DragEvent) => { - e.preventDefault(); - }; - const handleDocDrop = (e: DragEvent) => { - e.preventDefault(); - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - handleFileUpload(e.dataTransfer.files); - } - }; - document.addEventListener("dragover", handleDocDragOver); - document.addEventListener("drop", handleDocDrop); - return () => { - document.removeEventListener("dragover", handleDocDragOver); - document.removeEventListener("drop", handleDocDrop); - }; - }, [handleFileUpload]); // Run submissions are server-side rejected on beta (the backend returns // 403 with "Submit to spire-codex.com instead"). The Navbar already @@ -363,102 +292,7 @@ export default function SubmitRunClient() { /> )} - {/* Drop zone */} -
fileInputRef.current?.click()} - className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center cursor-pointer transition-colors ${ - isDragging - ? "border-[var(--accent-gold)] bg-[var(--accent-gold)]/5" - : "border-[var(--border-subtle)] hover:border-[var(--border-accent)]" - }`} - > - { - if (e.target.files) handleFileUpload(e.target.files); - e.target.value = ""; - }} - /> - {isDragging ? ( -

- {t("Drop files here...", lang)} -

- ) : ( -

- Drop .run files here or click to browse -

- )} - - {!isDragging && ( -
e.stopPropagation()}> - - Download Overwolf Companion App - - -
- )} - - {uploadProgress && ( -
-
-
-
-

- {uploadProgress.done === uploadProgress.total ? ( - <> - {t("Done!", lang)}{" "} - {uploadProgress.total - uploadProgress.dupes - uploadProgress.errors}{" "} - {t("submitted", lang)} - {uploadProgress.dupes > 0 && ( - <>, {uploadProgress.dupes} {t("duplicates skipped", lang)} - )} - {uploadProgress.errors > 0 && ( - <>, {uploadProgress.errors} {t("invalid", lang)} - )} - - ) : ( - <> - {t("Processing", lang)} {uploadProgress.done} {t("of", lang)}{" "} - {uploadProgress.total}... - - )} -

-
- )} - -
- -
+ {error && (

{error}

diff --git a/frontend/app/profile/ProfileClient.tsx b/frontend/app/profile/ProfileClient.tsx index a246233c..c680434c 100644 --- a/frontend/app/profile/ProfileClient.tsx +++ b/frontend/app/profile/ProfileClient.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useAuth } from "@/app/contexts/AuthContext"; import { useToast } from "@/app/components/Toast"; -import RunFileHelp from "@/app/components/RunFileHelp"; +import RunDropZone from "@/app/components/RunDropZone"; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; @@ -47,8 +47,6 @@ export default function ProfileClient() { const [uploading, setUploading] = useState(false); const [uploadResults, setUploadResults] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); - const [dragOver, setDragOver] = useState(false); - const fileRef = useRef(null); const fetchRuns = useCallback(async (p: number) => { setRunsLoading(true); @@ -135,11 +133,6 @@ export default function ProfileClient() { } }; - const onDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files); - }; if (loading) { return ( @@ -169,48 +162,7 @@ export default function ProfileClient() { {/* Upload section */}

Claim Runs

-
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={onDrop} - onClick={() => fileRef.current?.click()} - className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center cursor-pointer transition-colors ${ - dragOver - ? "border-[var(--border-accent)] bg-[var(--border-accent)]/5" - : "border-[var(--border-subtle)] hover:border-[var(--border-accent)]" - }`} - > - { - if (e.target.files?.length) handleUpload(e.target.files); - e.target.value = ""; - }} - /> - {uploading ? ( -

Uploading...

- ) : ( -

- Drop .run files here or click to browse -

- )} -
- - + handleUpload(files)} uploading={uploading} /> {uploadResults && uploadResults.length > 0 && (
From bdbaed1732b0820260ee16cb7995515d5a05bf8e Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 26 May 2026 22:07:04 -0700 Subject: [PATCH 6/6] Increase username limit from 25 to 32 characters Steam names can be up to 32 characters. Users with long names like "big boy only getting bigger" were getting truncated, causing run history lookups to fail. --- backend/app/routers/runs.py | 4 ++-- backend/app/services/users_db.py | 2 +- frontend/app/leaderboards/submit/SubmitRunClient.tsx | 4 ++-- frontend/app/settings/SettingsClient.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/runs.py b/backend/app/routers/runs.py index 57371f0f..7d6e4280 100644 --- a/backend/app/routers/runs.py +++ b/backend/app/routers/runs.py @@ -90,7 +90,7 @@ async def submit_run_endpoint(request: Request, username: str | None = None): if username: import re - sanitized = re.sub(r"[^a-zA-Z0-9_\- ]", "", username.strip())[:25].strip() + sanitized = re.sub(r"[^a-zA-Z0-9_\- ]", "", username.strip())[:32].strip() clean_username = sanitized or None result = submit_run(data, username=clean_username) @@ -156,7 +156,7 @@ async def claim_runs_endpoint(request: Request): import re - sanitized = re.sub(r"[^a-zA-Z0-9_\- ]", "", raw_username.strip())[:25].strip() + sanitized = re.sub(r"[^a-zA-Z0-9_\- ]", "", raw_username.strip())[:32].strip() if not sanitized: raise HTTPException( status_code=400, detail="username is empty after sanitization" diff --git a/backend/app/services/users_db.py b/backend/app/services/users_db.py index 2ffd2eab..a226c01c 100644 --- a/backend/app/services/users_db.py +++ b/backend/app/services/users_db.py @@ -29,7 +29,7 @@ _coll = None _USERNAME_RE = re.compile(r"[^a-zA-Z0-9_\- ]") -_USERNAME_MAX = 25 +_USERNAME_MAX = 32 _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _USERNAME_CHANGES_PER_DAY = 3 diff --git a/frontend/app/leaderboards/submit/SubmitRunClient.tsx b/frontend/app/leaderboards/submit/SubmitRunClient.tsx index 2a2db85d..ecfddaa2 100644 --- a/frontend/app/leaderboards/submit/SubmitRunClient.tsx +++ b/frontend/app/leaderboards/submit/SubmitRunClient.tsx @@ -285,9 +285,9 @@ export default function SubmitRunClient() { setUsername(e.target.value.slice(0, 25))} + onChange={(e) => setUsername(e.target.value.slice(0, 32))} placeholder={t("Username (optional)", lang)} - maxLength={25} + maxLength={32} className="px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-gold)] w-full sm:w-48" /> )} diff --git a/frontend/app/settings/SettingsClient.tsx b/frontend/app/settings/SettingsClient.tsx index eaf0429a..8525731c 100644 --- a/frontend/app/settings/SettingsClient.tsx +++ b/frontend/app/settings/SettingsClient.tsx @@ -133,7 +133,7 @@ export default function SettingsClient() { type="text" value={username} onChange={(e) => setUsername(e.target.value)} - maxLength={25} + maxLength={32} placeholder="Enter display name" className="w-full px-3 py-2 rounded-lg bg-[var(--bg-card)] border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--border-accent)]" /> @@ -156,7 +156,7 @@ export default function SettingsClient() {

- Letters, numbers, spaces, hyphens, underscores. Max 25 characters. {changesRemaining} changes remaining today. + Letters, numbers, spaces, hyphens, underscores. Max 32 characters. {changesRemaining} changes remaining today.