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 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/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/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()}> -
- - Download Overwolf Companion App - -
- +
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..ecfddaa2 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 @@ -356,83 +285,14 @@ 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" /> )} - {/* 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 -

- )} - - {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 bd8ad119..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,40 +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 && (
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.