diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx
index 8129e577..5ba47f01 100644
--- a/client/src/components/Leaderboard.jsx
+++ b/client/src/components/Leaderboard.jsx
@@ -6,75 +6,11 @@ import axios from 'axios';
import './Leaderboard.css';
import defaultProfileImage from '../assets/icons/default-profile.svg';
import ProfileModal from './ProfileModal.jsx';
+import SegmentedToggle from './SegmentedToggle';
const DURATIONS = [15, 30, 60, 120];
const PERIODS = ['daily', 'alltime'];
-function SegmentedToggle({
- options,
- value,
- onChange,
- className = '',
- ariaLabel,
-}) {
- const activeIndexRaw = options.findIndex(option => option.value === value);
- const activeIndex = activeIndexRaw >= 0 ? activeIndexRaw : 0;
- const total = options.length || 1;
- const classes = ['segmented-toggle', className].filter(Boolean).join(' ');
-
- const handleKeyDown = (event, index) => {
- if (!options.length) return;
- if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
- event.preventDefault();
- const next = (index + 1) % total;
- onChange(options[next].value);
- } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
- event.preventDefault();
- const prev = (index - 1 + total) % total;
- onChange(options[prev].value);
- }
- };
-
- return (
-
handleCardClick(e, mode)}
>
{mode.iconClass &&
}
diff --git a/client/src/components/Navbar.css b/client/src/components/Navbar.css
index 23be22b6..ff466a6f 100644
--- a/client/src/components/Navbar.css
+++ b/client/src/components/Navbar.css
@@ -1,3 +1,7 @@
+/* ============================================
+ Navbar - Glassmorphism Style
+ ============================================ */
+
.navbar {
position: relative;
top: 0;
@@ -9,6 +13,16 @@
justify-content: space-between;
z-index: 1000;
padding-top: 1.5rem;
+ transition: background-color 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease;
+}
+
+/* Glassmorphism effect when scrolled (can be toggled via JS) */
+.navbar.scrolled {
+ background: rgba(18, 18, 18, 0.85);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.navbar-logo {
@@ -33,15 +47,15 @@
width: 36px;
height: 36px;
color: var(--mode-text-color);
- border-radius: 6px;
- transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
+ border-radius: 8px;
+ transition: all 0.2s ease;
background: none;
border: none;
cursor: pointer;
}
.navbar-feedback-icon:hover {
- background-color: rgba(245, 128, 37, 0.1);
+ background-color: rgba(245, 128, 37, 0.12);
color: #F58025;
transform: translateY(-1px);
}
@@ -74,12 +88,14 @@
width: 36px;
height: 36px;
color: var(--mode-text-color);
- border-radius: 6px;
- transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+ background: none;
+ border: none;
}
.navbar-github-icon:hover {
- background-color: rgba(245, 128, 37, 0.1);
+ background-color: rgba(245, 128, 37, 0.12);
color: #F58025;
transform: translateY(-1px);
}
@@ -207,8 +223,14 @@
max-height: 75px;
width: auto;
object-fit: contain;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
- transition: transform 0.2s ease;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.4))
+ drop-shadow(0 0 15px rgba(245, 128, 37, 0.15));
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.3s ease;
+}
+
+.navbar-logo img:hover {
+ filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5))
+ drop-shadow(0 0 25px rgba(245, 128, 37, 0.3));
}
.navbar-link {
@@ -233,19 +255,45 @@
}
.login-nav-button {
- background-color: #F58025;
- color: #121212;
+ position: relative;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 50%, #F58025 100%);
+ background-size: 200% 200%;
+ color: #161616;
border: none;
- padding: 0.5rem 1.2rem;
- border-radius: 4px;
+ padding: 0.55rem 1.4rem;
+ border-radius: 8px;
cursor: pointer;
- transition: all 0.2s;
- font-weight: 500;
+ transition: transform 0.25s ease, box-shadow 0.25s ease, background-position 0.5s ease;
+ font-weight: 600;
+ box-shadow:
+ 0 2px 12px rgba(245, 128, 37, 0.3),
+ 0 4px 20px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+}
+
+.login-nav-button::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.25),
+ transparent
+ );
+ transform: skewX(-20deg);
+ animation: shimmer 3s ease-in-out infinite;
}
.login-nav-button:hover {
- background-color: #e67016;
- box-shadow: 0 2px 4px rgba(245, 128, 37, 0.3);
+ transform: translateY(-2px);
+ background-position: 100% 50%;
+ box-shadow:
+ 0 4px 18px rgba(245, 128, 37, 0.45),
+ 0 6px 25px rgba(0, 0, 0, 0.25);
}
@media (max-width: 900px) {
diff --git a/client/src/components/ProfileModal.css b/client/src/components/ProfileModal.css
index 1fe205c4..e8feeb95 100644
--- a/client/src/components/ProfileModal.css
+++ b/client/src/components/ProfileModal.css
@@ -1,1019 +1,1445 @@
+/* ProfileModal.css - Redesigned Profile Page Styles */
/* alignment debugged with ai */
+
+/* ========== OVERLAY & CONTAINER ========== */
.profile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
- background-color: rgba(0, 0, 0, 0.75);
- backdrop-filter: blur(5px);
+ background-color: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
+ padding: 1rem;
}
.profile-container {
- width: 110vw;
+ width: 95vw;
+ max-width: 1200px;
height: auto;
- position: absolute;
- margin: 2rem auto;
- padding: 2rem;
+ max-height: 95vh;
background-color: var(--secondary-color);
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- border-style: solid;
- border-radius: 30px;
- border-color: #F58025;
- align-items: center;
- scale: 80%;
- max-height: 110vh;
+ border-radius: 24px;
+ border: 1px solid rgba(245, 128, 37, 0.3);
+ box-shadow:
+ 0 25px 80px rgba(0, 0, 0, 0.5),
+ 0 0 60px rgba(245, 128, 37, 0.1);
overflow-y: auto;
- animation: fadeIn 0.25s ease-out;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
+ overflow-x: hidden;
+ position: relative;
+ animation: modalSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
-.loading-container {
- width: 90vw;
- margin: 2rem auto;
- padding: 2rem;
- text-align: center;
- font-size: 1.2rem;
- color: var(--secondary-color);
+@keyframes modalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(30px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
-.stats-loading {
- text-align: center;
- padding: 2rem;
- color: var(--secondary-color);
+/* Custom scrollbar for profile container */
+.profile-container::-webkit-scrollbar {
+ width: 8px;
}
-/* alignment debugged with ai */
-.back-button-container {
- padding-bottom: 2rem;
- display: flex;
- flex-direction: row;
- align-items: center;
- position: relative;
+.profile-container::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 24px 0;
}
-.back-button-profile {
- position: absolute;
- left: 0;
- background-color: transparent;
- border: 1px solid #F58025;
- color: #F58025;
- padding: 0.5rem 1rem;
+.profile-container::-webkit-scrollbar-thumb {
+ background: rgba(245, 128, 37, 0.6);
border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- z-index: 1;
- font-size: 1.5rem;
}
-.back-button-profile span {
- margin-right: 0.5rem;
+.profile-container::-webkit-scrollbar-thumb:hover {
+ background: #F58025;
}
-.back-button-profile:hover {
- background-color: rgba(245, 128, 37, 0.1);
-}
-
-.profile-title {
- flex: 1;
- text-align: center;
- color: var(--mode-text-color);
+/* Close Button */
+.profile-close-btn {
+ position: absolute;
+ top: 1.25rem;
+ right: 1.25rem;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ z-index: 100;
}
-.profile-title h2{
- font-size: 2rem;
+.profile-close-btn:hover {
+ background-color: rgba(245, 128, 37, 0.3);
+ border-color: #F58025;
+ transform: rotate(90deg);
}
-.profile-header {
- text-align: center;
- margin-bottom: 1.5rem;
+.profile-close-btn .material-icons {
+ font-size: 24px;
}
-
-.profile-components {
- display:flex;
- flex-direction: row;
- gap: 2rem;
+/* Loading States */
+.profile-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 400px;
}
-.profile-header-info {
- display:flex;
+.profile-loader {
+ display: flex;
flex-direction: column;
- flex: 1 1 55%;
- min-width: 0;
+ align-items: center;
+ gap: 1rem;
+ color: var(--mode-text-color);
}
-.profile-page-info {
- display: flex;
- align-items: flex-start;
- gap: 1.875rem;
- padding-bottom: 1.5rem;
+.loader-ring {
+ width: 50px;
+ height: 50px;
+ border: 3px solid rgba(245, 128, 37, 0.2);
+ border-top-color: #F58025;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
}
-.profile-page-info h2 {
- color: var(--mode-text-color);
- margin-bottom: 0.5rem;
+.loader-ring.small {
+ width: 30px;
+ height: 30px;
+ border-width: 2px;
}
-.profile-page-info p {
- color: #f8f9fa;
+@keyframes spin {
+ to { transform: rotate(360deg); }
}
-.profile-page-image {
- border-radius: 30px;
- border: solid;
- border-color: #f8f9fa;
- width: 235px;
- height: 235px;
+/* ========== HERO SECTION ========== */
+.profile-hero {
position: relative;
+ padding: 1.5rem 2rem 1.25rem;
overflow: hidden;
- cursor: pointer;
- transition: all 0.3s ease;
- flex-shrink: 0;
+ border-radius: 24px 24px 0 0;
}
-.profile-page-image:hover {
- border-color: #F58025;
- opacity: 0.9;
+/* Rank-based hero backgrounds (Princeton-themed tiers) */
+.profile-hero.freshman {
+ background: linear-gradient(135deg, #151515 0%, #1e1e1e 50%, #151515 100%);
}
-.profile-page-image input[type="image"] {
- width: 100%;
- height: 100%;
- object-fit: cover;
- cursor: pointer;
+.profile-hero.sophomore {
+ background: linear-gradient(135deg, #0f1a24 0%, #152535 50%, #0f1a24 100%);
}
-.profile-page-image input[type="image"].uploading {
- opacity: 0.5;
+.profile-hero.junior {
+ background: linear-gradient(135deg, #0f1a14 0%, #15251a 50%, #0f1a14 100%);
}
-.avatar-hover-hint {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: rgba(0, 0, 0, 0.45);
- color: #ffffff;
- font-weight: 700;
- letter-spacing: 0.3px;
- opacity: 0;
- transition: opacity 0.15s ease-in-out;
- text-align: center;
- padding: 0 10px;
- pointer-events: none; /* let clicks fall through to the image input */
+.profile-hero.senior {
+ background: linear-gradient(135deg, #1a1208 0%, #2d1f0d 50%, #1a1208 100%);
}
-.profile-page-image:hover .avatar-hover-hint {
- opacity: 1;
+.profile-hero.gradstudent {
+ background: linear-gradient(135deg, #1a0f20 0%, #251530 50%, #1a0f20 100%);
}
-.static-avatar {
- width: 100%;
- height: 100%;
- object-fit: cover;
+.profile-hero.supersenior {
+ background: linear-gradient(135deg, #1a0f0f 0%, #2d1515 50%, #1a0f0f 100%);
}
-.upload-overlay {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- background-color: rgba(0, 0, 0, 0.6);
- color: white;
- font-weight: bold;
+.profile-hero.einstein {
+ background: linear-gradient(135deg, #1a1400 0%, #2d2000 50%, #1a1400 100%);
}
-.profile-error-message {
+/* Hero Background Effects */
+.hero-background {
position: absolute;
- bottom: 0;
+ top: 0;
left: 0;
- width: 100%;
- background-color: rgba(255, 0, 0, 0.8);
- color: white;
- padding: 5px;
- text-align: center;
- font-size: 0.8rem;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ overflow: hidden;
}
-.success-message {
+.hero-glow {
position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- background-color: rgba(40, 167, 69, 0.8);
- color: white;
- padding: 5px;
- text-align: center;
- font-size: 0.8rem;
+ width: 400px;
+ height: 400px;
+ border-radius: 50%;
+ filter: blur(100px);
+ opacity: 0.4;
+ top: -100px;
+ left: 50%;
+ transform: translateX(-50%);
+ animation: glowPulse 3s ease-in-out infinite;
}
-.selectable-info {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
+@keyframes glowPulse {
+ 0%, 100% {
+ opacity: 0.4;
+ transform: translateX(-50%) scale(1);
+ }
+ 50% {
+ opacity: 0.6;
+ transform: translateX(-50%) scale(1.1);
+ }
}
-.username-info {
- display: flex;
- width: 500px;
- justify-content: space-between;
+.profile-hero.freshman .hero-glow {
+ background: radial-gradient(circle, #95A5A6 0%, transparent 70%);
}
-.username-info h2 {
- font-size: 2rem;
+.profile-hero.sophomore .hero-glow {
+ background: radial-gradient(circle, #3498DB 0%, transparent 70%);
}
-.title-select {
- position: relative;
- width: 95%;
- margin-bottom: 0.5rem;
+.profile-hero.junior .hero-glow {
+ background: radial-gradient(circle, #2ECC71 0%, transparent 70%);
}
-.selected-title:hover{
- border-color: var(--mode-text-color);
+.profile-hero.senior .hero-glow {
+ background: radial-gradient(circle, #F58025 0%, transparent 70%);
}
-.selected-title {
- background-color: var(--background-color);
- color: var(--mode-text-color);
- border: 1px solid #F58025;
- border-radius: 4px;
- padding: 0.5rem 0.8rem;
- font-size: 1.5rem;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: border-color 0.2s ease;
+.profile-hero.gradstudent .hero-glow {
+ background: radial-gradient(circle, #9B59B6 0%, transparent 70%);
}
-.dropdown-arrow {
- color: #F58025;
- font-size: 0.8rem;
- transition: transform 0.2s;
+.profile-hero.supersenior .hero-glow {
+ background: radial-gradient(circle, #E74C3C 0%, transparent 70%);
}
-.title-dropdown {
- position: absolute;
- top: 100%;
- left: 0;
- width: 100%;
- background-color: var(--background-color);
- border: 1px solid #F58025;
- border-top: none;
- border-radius: 0 0 4px 4px;
- z-index: 100;
- max-height: 300px;
- overflow-y: auto;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- animation: slideDown 0.2s ease-out;
+.profile-hero.einstein .hero-glow {
+ background: radial-gradient(circle, #FFD700 0%, transparent 70%);
+ animation: glowPulseIntense 2s ease-in-out infinite;
}
-@keyframes slideDown {
- from {
- opacity: 0;
- transform: translateY(-10px);
+/* Intense glow animation for highest tier */
+@keyframes glowPulseIntense {
+ 0%, 100% {
+ opacity: 0.5;
+ transform: translateX(-50%) scale(1);
+ filter: blur(100px);
}
- to {
- opacity: 1;
- transform: translateY(0);
+ 50% {
+ opacity: 0.8;
+ transform: translateX(-50%) scale(1.15);
+ filter: blur(90px);
}
}
-.dropdown-option {
- padding: 0.8rem 1rem;
- font-size: 1.1rem;
- cursor: pointer;
- transition: all 0.2s ease;
- text-align: left;
- display: flex;
- flex-direction: column;
- gap: 4px;
- border-bottom: 1px solid rgba(245, 128, 37, 0.1);
-}
-
-.dropdown-option:last-child {
- border-bottom: none;
-}
-
-.dropdown-option:hover {
- background-color: rgba(245, 128, 37, 0.1);
+/* Enhanced glow for higher tiers */
+.profile-hero.supersenior .hero-glow {
+ animation: glowPulse 2.5s ease-in-out infinite;
}
-.dropdown-option.locked {
- opacity: 0.6;
- cursor: not-allowed;
- background-color: rgba(0, 0, 0, 0.05);
+.profile-hero.gradstudent .hero-glow {
+ animation: glowPulse 2.8s ease-in-out infinite;
}
-.dropdown-option.locked:hover {
- background-color: rgba(0, 0, 0, 0.1);
+.hero-particles {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
+ radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
+ background-size: 100px 100px, 150px 150px, 120px 120px;
+ animation: particleDrift 20s linear infinite;
+}
+
+/* Enhanced particles for higher tiers */
+.profile-hero.senior .hero-particles,
+.profile-hero.gradstudent .hero-particles {
+ background-image:
+ radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
+ radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
+ radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
+ radial-gradient(circle at 60% 20%, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ radial-gradient(circle at 10% 60%, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
+ background-size: 80px 80px, 120px 120px, 100px 100px, 90px 90px, 110px 110px;
+ animation: particleDriftFast 15s linear infinite;
+}
+
+.profile-hero.supersenior .hero-particles {
+ background-image:
+ radial-gradient(circle at 20% 30%, rgba(231, 76, 60, 0.08) 1.5px, transparent 1.5px),
+ radial-gradient(circle at 80% 70%, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
+ radial-gradient(circle at 40% 80%, rgba(231, 76, 60, 0.06) 1px, transparent 1px),
+ radial-gradient(circle at 60% 20%, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
+ radial-gradient(circle at 10% 60%, rgba(231, 76, 60, 0.07) 1.5px, transparent 1.5px);
+ background-size: 70px 70px, 110px 110px, 90px 90px, 80px 80px, 100px 100px;
+ animation: particleDriftFast 12s linear infinite;
+}
+
+/* Golden shimmer particles for Einstein tier */
+.profile-hero.einstein .hero-particles {
+ background-image:
+ radial-gradient(circle at 20% 30%, rgba(255, 215, 0, 0.12) 2px, transparent 2px),
+ radial-gradient(circle at 80% 70%, rgba(255, 215, 0, 0.1) 1.5px, transparent 1.5px),
+ radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
+ radial-gradient(circle at 60% 20%, rgba(255, 215, 0, 0.08) 1.5px, transparent 1.5px),
+ radial-gradient(circle at 10% 60%, rgba(255, 255, 255, 0.06) 1px, transparent 1px),
+ radial-gradient(circle at 90% 40%, rgba(255, 215, 0, 0.1) 2px, transparent 2px);
+ background-size: 60px 60px, 100px 100px, 80px 80px, 70px 70px, 90px 90px, 85px 85px;
+ animation: particleDriftGold 10s linear infinite, particleShimmer 3s ease-in-out infinite;
+}
+
+@keyframes particleDrift {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-10px); }
+}
+
+@keyframes particleDriftFast {
+ 0%, 100% { transform: translateY(0) translateX(0); }
+ 25% { transform: translateY(-8px) translateX(3px); }
+ 50% { transform: translateY(-15px) translateX(0); }
+ 75% { transform: translateY(-8px) translateX(-3px); }
+}
+
+@keyframes particleDriftGold {
+ 0%, 100% { transform: translateY(0) translateX(0) rotate(0deg); }
+ 25% { transform: translateY(-10px) translateX(5px) rotate(1deg); }
+ 50% { transform: translateY(-20px) translateX(0) rotate(0deg); }
+ 75% { transform: translateY(-10px) translateX(-5px) rotate(-1deg); }
+}
+
+@keyframes particleShimmer {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+/* Hero Content Layout */
+.hero-content {
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ position: relative;
+ z-index: 1;
}
-.title-locked-indicator {
+/* ========== AVATAR SECTION ========== */
+.profile-avatar-section {
display: flex;
+ flex-direction: column;
align-items: center;
- gap: 4px;
- font-size: 0.9rem;
- color: #888;
- margin-top: 4px;
+ gap: 0.75rem;
}
-.title-locked-indicator .material-icons {
- font-size: 1rem;
+.avatar-container {
+ position: relative;
+ width: 130px;
+ height: 130px;
}
-.dropdown-option.loading,
-.dropdown-option.disabled {
- font-style: italic;
- cursor: default;
- padding: 1rem;
- text-align: center;
+.avatar-ring {
+ position: absolute;
+ top: -6px;
+ left: -6px;
+ right: -6px;
+ bottom: -6px;
+ border-radius: 50%;
+ padding: 3px;
+ background: conic-gradient(from 0deg, transparent 0%, var(--ring-color, #F58025) 25%, transparent 50%, var(--ring-color, #F58025) 75%, transparent 100%);
+ animation: ringRotate 4s linear infinite;
+ opacity: 0.6;
}
-.title-description {
- font-size: 0.9rem;
- color: #929292;
- font-style: italic;
- margin-left: 8px;
- border-left: 2px solid rgba(245, 128, 37, 0.3);
- padding-left: 8px;
-}
+.avatar-container.freshman .avatar-ring { --ring-color: #95A5A6; }
+.avatar-container.sophomore .avatar-ring { --ring-color: #3498DB; }
+.avatar-container.junior .avatar-ring { --ring-color: #2ECC71; }
+.avatar-container.senior .avatar-ring { --ring-color: #F58025; }
+.avatar-container.gradstudent .avatar-ring { --ring-color: #9B59B6; }
+.avatar-container.supersenior .avatar-ring { --ring-color: #E74C3C; }
+.avatar-container.einstein .avatar-ring { --ring-color: #FFD700; opacity: 0.8; }
-.deselect-option {
- color: #F58025;
- font-weight: 500;
- border-bottom: 1px solid rgba(245, 128, 37, 0.3);
+@keyframes ringRotate {
+ to { transform: rotate(360deg); }
}
-.deselect-option:hover {
- background-color: rgba(245, 128, 37, 0.15);
+.avatar-wrapper {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 4px solid var(--secondary-color);
+ background-color: var(--hover-color);
}
-/* When dropdown is open, change arrow direction */
-.title-select.open .dropdown-arrow {
- transform: rotate(180deg);
+.avatar-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ cursor: pointer;
+ transition: transform 0.3s ease;
}
-.user-badges h3{
- text-align: left;
- font-size: 1.8rem;
- margin-bottom: 0.5rem;
+.avatar-image.uploading {
+ opacity: 0.5;
}
-
-.profile-user-edit {
- width: 1.875rem;
- height: 1.875rem;
- mix-blend-mode: screen;
+.avatar-wrapper:hover .avatar-image {
+ transform: scale(1.05);
}
-.biography {
- padding-top: 2.2rem;
- padding-right: 2rem;
+/* Avatar Edit Overlay */
+.avatar-edit-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
display: flex;
flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.25rem;
+ background-color: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ font-size: 0.75rem;
+ font-weight: 600;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ pointer-events: none;
}
-.biography-input {
- box-sizing: border-box;
- resize: none;
- border-radius: 15px;
- padding: 10px;
- width: 100%;
- height: 200px;
- background-color: var(--hover-color);
- color: #f8f9fa;
- border: 1px solid #444;
- font-family: inherit;
- font-size: 1.5rem;
- margin-bottom: 10px;
- flex-grow: 1;
-}
-
-.biography-input::placeholder {
- color: gray;
+.avatar-edit-overlay .material-icons {
+ font-size: 28px;
}
-.biography-input:focus {
- outline: none;
- border-color: #F58025;
- box-shadow: 0 0 0 0.2rem rgba(245, 128, 37, 0.25);
+.avatar-wrapper:hover .avatar-edit-overlay {
+ opacity: 1;
}
-.bio-controls {
+/* Upload Progress */
+.avatar-upload-progress {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
display: flex;
align-items: center;
- flex-direction: row-reverse;
- margin-top: 0.5rem;
+ justify-content: center;
+ background-color: rgba(0, 0, 0, 0.7);
}
-.save-bio-btn {
- background-color: #F58025;
- border: none;
- color: white;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- font-weight: bold;
- font-size: 1.2rem;
+.upload-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(255, 255, 255, 0.2);
+ border-top-color: #F58025;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
}
-.save-bio-btn:hover {
- background-color: #e06800;
+/* Rank Badge */
+.rank-badge {
+ position: absolute;
+ bottom: -10px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 0.35rem 1rem;
+ border-radius: 20px;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #000;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ white-space: nowrap;
}
-.save-bio-btn:disabled {
- background-color: #ccc;
- cursor: not-allowed;
+/* Upload Messages */
+.upload-message {
+ padding: 0.5rem 1rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ text-align: center;
+ max-width: 200px;
}
-.bio-success, .bio-error {
- margin-left: 20px;
- padding: 5px 10px;
- border-radius: 4px;
- font-size: 1.3rem;
- margin-right: 20px;
+.upload-message.error {
+ background-color: rgba(220, 53, 69, 0.2);
+ color: #ff6b6b;
+ border: 1px solid rgba(220, 53, 69, 0.3);
}
-.bio-success {
+.upload-message.success {
background-color: rgba(40, 167, 69, 0.2);
- color: #28a745;
-}
-
-.bio-error {
- background-color: rgba(220, 53, 69, 0.2);
- color: #dc3545;
+ color: #51cf66;
+ border: 1px solid rgba(40, 167, 69, 0.3);
}
-.match-history {
- flex: 1 1 45%;
- min-width: 0;
- font-size: 2rem;
+/* ========== USER IDENTITY ========== */
+.user-identity {
+ flex: 1;
display: flex;
flex-direction: column;
+ gap: 0.5rem;
}
-.match-history h2 {
+.username {
font-size: 2rem;
- color: var(--mode-text-color);
- margin-bottom: 0.5rem;
+ font-weight: 700;
+ color: #fff;
+ margin: 0;
+ letter-spacing: -0.5px;
+ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
-.match-history-list {
+/* Title + Stats Row */
+.title-stats-row {
display: flex;
- flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
gap: 1rem;
- padding-top: 5px;
- padding-right: 10px;
- padding-left: 10px;
- height: 400px;
- overflow-y: auto;
+ margin-right: -2rem; /* Extend to edge of hero padding */
}
-/* Custom Scrollbar for Match History */
-/* i like this alot should make it global later */
-.match-history-list::-webkit-scrollbar {
- width: 8px;
+/* ========== TITLE SELECTOR ========== */
+.title-selector {
+ position: relative;
+ max-width: 300px;
}
-.match-history-list::-webkit-scrollbar-track {
- background: var(--secondary-color);
- border-radius: 4px;
+
+.title-button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.6rem 1rem;
+ background-color: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 10px;
+ color: var(--mode-text-color);
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
}
-.match-history-list::-webkit-scrollbar-thumb {
- background-color: #F58025;
- border-radius: 4px;
- border: 2px solid var(--secondary-color);
+
+.title-button:hover,
+.title-button.active {
+ background-color: rgba(245, 128, 37, 0.15);
+ border-color: #F58025;
}
-.match-history-list::-webkit-scrollbar-thumb:hover {
- background-color: #e06800;
+
+.title-text {
+ font-weight: 500;
}
-.match-history-card {
- background-color: var(--hover-color);
- border-radius: 8px;
- padding: 1rem 1.2rem;
- border: 1px solid rgba(245, 128, 37, 0.2);
- transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
- position: relative;
+.dropdown-chevron {
display: flex;
- flex-direction: column;
+ transition: transform 0.2s ease;
}
-.match-history-card:hover {
- transform: translateY(-3px) scale(1.01);
- border-color: #F58025;
- box-shadow: 0 4px 10px rgba(245, 128, 37, 0.15);
+.dropdown-chevron.open {
+ transform: rotate(180deg);
}
-.match-date {
- position: absolute;
- top: 0.5rem;
- right: 0.8rem;
- font-size: 0.9rem;
- color: var(--text-color-secondary);
- background-color: rgba(0, 0, 0, 0.2);
- padding: 2px 5px;
- border-radius: 4px;
+.dropdown-chevron .material-icons {
+ font-size: 20px;
+ color: #F58025;
}
-.match-details {
- display: grid;
- grid-template-columns: auto 1fr;
- align-items: center;
- gap: 1.5rem;
- padding-top: 0.8rem;
- min-height: 60px;
+/* Title Dropdown - Uses position: fixed to escape stacking context */
+.title-dropdown {
+ /* position set via inline style for dynamic positioning */
+ background-color: var(--secondary-color);
+ border: 1px solid rgba(245, 128, 37, 0.3);
+ border-radius: 12px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+ z-index: 9999;
+ max-height: 300px;
+ overflow-y: auto;
+ animation: dropdownSlide 0.2s ease-out;
}
-.match-position {
- grid-column: 1 / 2;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 0 0.8rem;
- align-self: stretch;
- border-right: 1px solid rgba(245, 128, 37, 0.2);
- margin-left: 0;
- background-color: transparent;
- border-left: none;
- border-radius: 0;
- text-align: center;
+@keyframes dropdownSlide {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
-.match-info-details {
- grid-column: 2 / 3;
- display: flex;
- justify-content: space-between;
- align-items: center;
+.dropdown-header {
+ padding: 0.75rem 1rem;
+ font-size: 0.8rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #888;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
-.match-type {
- font-size: 1rem;
- color: var(--mode-text-color);
+.title-option {
display: flex;
- flex-direction: column;
- text-align: left;
- gap: 0.2rem;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.9rem 1rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
-.match-lobby-type {
- font-weight: 600;
- font-size: 1.1rem;
+.title-option:last-child {
+ border-bottom: none;
}
-.match-category {
- font-style: italic;
- font-size: 0.9rem;
- color: var(--text-color-secondary);
+.title-option:hover {
+ background-color: rgba(245, 128, 37, 0.1);
}
-.match-position .position-number {
- font-size: 2.4rem;
- font-weight: bold;
- line-height: 1.1;
- color: var(--text-color-secondary);
+.title-option.selected {
+ background-color: rgba(245, 128, 37, 0.15);
}
-.match-position-label {
- font-size: 0.8rem;
- color: var(--text-color-secondary);
- margin-top: -2px;
- text-transform: uppercase;
+.title-option.locked {
+ opacity: 0.5;
+ cursor: not-allowed;
}
-.match-position.first-place .position-number {
- color: #FFD700;
- text-shadow: 0 0 6px rgba(255, 215, 0, 0.6);
+.title-option.locked:hover {
+ background-color: rgba(255, 255, 255, 0.03);
}
-.match-position.second-place .position-number {
- color: #C0C0C0;
- text-shadow: 0 0 5px rgba(192, 192, 192, 0.4);
+.title-option.deselect {
+ color: #F58025;
+ font-weight: 500;
}
-.match-position.third-place .position-number {
- color: #CD7F32;
- text-shadow: 0 0 5px rgba(205, 127, 50, 0.4);
+.title-option.loading,
+.title-option.disabled {
+ justify-content: center;
+ color: #888;
+ font-style: italic;
+ cursor: default;
}
-.match-stats {
+.option-content {
display: flex;
flex-direction: column;
- align-items: flex-end;
- gap: 0.3rem;
+ gap: 0.25rem;
}
-.match-stats span {
- font-size: 1.1rem;
- color: #F58025;
- display: flex;
- align-items: center;
- gap: 0.4rem;
+.option-name {
+ font-weight: 500;
+ color: var(--mode-text-color);
}
-/* Add icons to match stats */
-.match-stats span i {
- font-size: 1rem;
- line-height: 1;
+.option-desc {
+ font-size: 0.8rem;
+ color: #888;
+}
+
+.lock-icon,
+.check-icon {
+ font-size: 18px;
+}
+
+.lock-icon {
+ color: #666;
}
-.profile-stats h2 {
+.check-icon {
color: #F58025;
- margin-bottom: 1.5rem;
- font-size: 2rem;
}
-.primary-stats {
- margin-bottom: 1.5rem;
+/* Read-only Title Display */
+.title-display {
+ padding: 0.5rem 0;
}
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 1.5rem;
+.title-loading {
+ color: #888;
+ font-style: italic;
}
-.stat-card {
- background: linear-gradient(135deg, var(--hover-color) 0%, var(--secondary-color) 100%);
+.equipped-title {
color: var(--mode-text-color);
- padding: 1.4rem;
- border-radius: 8px;
- border: 1px solid rgba(245, 128, 37, 0.2);
- text-align: center;
- transition: transform 0.2s ease, box-shadow 0.2s ease;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: center;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ font-size: 1.1rem;
+ font-weight: 500;
+ cursor: help;
}
-.profile-stat {
- flex-direction: column!important;
+.no-title {
+ color: #666;
+ font-style: italic;
}
-.stat-card
-
-.stat-card:hover {
- transform: translateY(-5px);
- box-shadow: 0 5px 15px rgba(245, 128, 37, 0.2);
+/* ========== HERO QUICK STATS ========== */
+.hero-quick-stats {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 0.6rem 1.25rem;
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.5) 100%);
+ border-radius: 12px 0 0 12px; /* Flush right edge */
+ border: 1px solid rgba(245, 128, 37, 0.25);
+ border-right: none;
+ flex-shrink: 0;
+ box-shadow:
+ 0 4px 20px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
-.stat-card h3 {
- color: var(--mode-text-color);
- margin-bottom: 0.8rem;
- font-size: 1.5rem;
+.quick-stat {
display: flex;
+ flex-direction: column;
align-items: center;
- justify-content: center;
- gap: 0.5rem;
+ padding: 0 1rem;
}
-.stat-card h3 i {
- color: #F58025;
+.quick-stat-divider {
+ width: 1px;
+ height: 32px;
+ background: linear-gradient(180deg, transparent 0%, rgba(245, 128, 37, 0.4) 50%, transparent 100%);
+}
+
+.quick-stat .stat-value {
font-size: 1.4rem;
+ font-weight: 700;
+ color: #fff;
line-height: 1;
+ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
-.stat-card p {
- color: var(--text-color-highlight);
- font-size: 1.8rem;
- font-weight: bold;
- margin: 0;
+.quick-stat .stat-label {
+ font-size: 0.6rem;
+ font-weight: 600;
+ color: rgba(245, 128, 37, 0.8);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 0.25rem;
}
-/* Badge display and selection styling */
-.badge-display {
- min-height: 50px;
- display: flex;
- flex-wrap: wrap;
- gap: 2rem;
- margin-top: 10px;
- justify-content: flex-start;
+/* ========== RANK PROGRESS BAR ========== */
+.rank-progress-container {
+ margin-top: 0.5rem;
+ padding: 0.6rem 0.9rem;
+ background-color: rgba(0, 0, 0, 0.25);
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
}
-.badge-item {
- width: 50px;
- height: 50px;
- border-radius: 50%;
+.rank-progress-header {
display: flex;
- flex-direction: column;
- justify-content: center;
align-items: center;
- cursor: pointer;
- transition: all 0.2s ease;
- background-color: var(--hover-color);
- position: relative;
+ gap: 0.4rem;
+ margin-bottom: 0.4rem;
+ font-size: 0.8rem;
+ font-weight: 600;
}
-.badge-item.selected {
- background-color: rgba(245, 128, 37, 0.2);
- border: 2px solid #F58025;
+.current-rank {
+ text-shadow: 0 0 10px currentColor;
}
-.badge-item.placeholder {
- border: 2px dashed #777;
+.rank-arrow {
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.75rem;
}
-.badge-item:hover {
- transform: scale(1.05);
+.next-rank {
+ opacity: 0.7;
}
-.badge-item:hover .badge-name {
- opacity: 1;
+.rank-progress-bar {
+ height: 6px;
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+ overflow: hidden;
+ position: relative;
}
-.badge-emoji {
- font-size: 1.8rem;
+.rank-progress-fill {
+ height: 100%;
+ border-radius: 3px;
+ transition: width 0.5s ease;
+ box-shadow: 0 0 10px currentColor;
+ position: relative;
+ overflow: hidden;
}
-.badge-image {
- width: 4.5rem;
- height: 4.5rem;
- object-fit: contain;
+.rank-progress-fill::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -100%;
+ width: 50%;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.4) 50%,
+ transparent 100%
+ );
+ animation: progressShimmer 2s ease-in-out infinite;
}
-.badge-plus {
- font-size: 2rem;
- color: #777;
+@keyframes progressShimmer {
+ 0% { left: -50%; }
+ 100% { left: 150%; }
}
-.badge-name {
- position: absolute;
- bottom: -87%;
- left: 50%;
- transform: translateX(-50%);
- font-size: 0.9rem;
- color: var(--text-color);
- background-color: var(--container-color);
- padding: 4px 8px;
- border-radius: 4px;
- white-space: nowrap;
- opacity: 0;
- transition: opacity 0.2s ease;
- pointer-events: none;
- border: 1px solid #F58025;
+.rank-progress-text {
+ margin-top: 0.35rem;
+ font-size: 0.7rem;
+ color: rgba(255, 255, 255, 0.6);
}
-/* Badge selector overlay styling */
-.badge-selector-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.75);
- backdrop-filter: blur(5px);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1500;
+.rank-progress-text strong {
+ color: rgba(255, 255, 255, 0.9);
}
-.badge-selector {
- background-color: var(--secondary-color);
- border: 2px solid #F58025;
- border-radius: 10px;
- padding: 1.5rem;
- width: 90%;
- max-width: 600px;
- max-height: 80vh;
+/* ========== MAIN CONTENT GRID ========== */
+.profile-main {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ padding: 1.25rem;
+}
+
+.profile-column {
display: flex;
flex-direction: column;
- box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
+ gap: 1rem;
}
-.badge-selector h4 {
- color: var(--mode-text-color);
- margin-bottom: 1rem;
- font-size: 1.5rem;
- text-align: center;
+/* ========== PROFILE SECTIONS ========== */
+.profile-section {
+ background-color: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ overflow: hidden;
+ transition: border-color 0.2s ease;
+}
+
+.profile-section:hover {
+ border-color: rgba(245, 128, 37, 0.2);
}
-.badge-grid {
+.section-header {
display: flex;
- flex-direction: column;
- gap: 10px;
- overflow-y: auto;
- max-height: 400px;
- padding: 10px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
-.badge-selection-item {
+.section-header h3 {
display: flex;
align-items: center;
- padding: 10px;
- border-radius: 8px;
- cursor: pointer;
- transition: background-color 0.2s;
- background-color: rgba(255, 255, 255, 0.05);
+ gap: 0.4rem;
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--mode-text-color);
}
-.badge-selection-item:hover {
- background-color: rgba(245, 128, 37, 0.1);
+.section-header h3 .material-icons {
+ font-size: 18px;
+ color: #F58025;
}
-.badge-selection-item.selected {
- background-color: rgba(245, 128, 37, 0.2);
- border: 1px solid #F58025;
+.section-content {
+ padding: 1rem;
}
-.badge-details {
+/* ========== BIO SECTION ========== */
+.bio-editor {
display: flex;
flex-direction: column;
- margin-left: 15px;
+ gap: 0.75rem;
}
-.badge-details .badge-modal-name {
- position: static;
- transform: none;
- width: auto;
- font-weight: bold;
+.bio-textarea {
+ width: 100%;
+ min-height: 80px;
+ max-height: 120px;
+ padding: 0.75rem;
+ background-color: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
color: var(--mode-text-color);
- font-size: 1rem;
- margin: 0;
- text-align: left;
+ font-family: inherit;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ resize: vertical;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
-.badge-details .badge-modal-description {
- font-size: 0.9rem;
- color: var(--text-color-secondary);
- margin-top: 2px;
+.bio-textarea::placeholder {
+ color: #666;
+}
+
+.bio-textarea:focus {
+ outline: none;
+ border-color: #F58025;
+ box-shadow: 0 0 0 3px rgba(245, 128, 37, 0.15);
}
-.badge-selector-actions {
+.bio-footer {
display: flex;
- justify-content: flex-end;
- margin-top: 1.5rem;
- gap: 10px;
+ align-items: center;
+ justify-content: space-between;
}
-.badge-cancel, .badge-save {
- padding: 8px 16px;
- border-radius: 4px;
- font-weight: bold;
+.char-count {
+ font-size: 0.8rem;
+ color: #888;
+}
+
+.save-bio-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.5rem 1rem;
+ background: linear-gradient(135deg, #F58025 0%, #d16a1c 100%);
+ border: none;
+ border-radius: 6px;
+ color: #fff;
+ font-weight: 600;
+ font-size: 0.8rem;
cursor: pointer;
- font-size: 1rem;
transition: all 0.2s ease;
}
-.badge-cancel {
- background-color: transparent;
- color: var(--text-color-secondary);
- border: 1px solid #555;
+.save-bio-btn:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 15px rgba(245, 128, 37, 0.4);
}
-.badge-save {
- background-color: #F58025;
- color: var(--text-color-highlight);
- border: none;
+.save-bio-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
}
-.badge-loading, .no-badges {
- padding: 20px;
- text-align: center;
- color: var(--text-color-secondary);
- font-style: italic;
+.save-bio-btn .material-icons {
+ font-size: 18px;
}
-.no-badges-display {
- font-style: italic;
- color: var(--text-color-secondary);
- font-size: 1.1rem;
- padding: 10px 0;
- width: 100%;
- text-align: left;
- margin-left: 5px;
- min-height: 50px;
- display: flex;
- align-items: center;
+.btn-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.bio-message {
+ padding: 0.6rem 1rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+.bio-message.success {
+ background-color: rgba(40, 167, 69, 0.15);
+ color: #51cf66;
+ border: 1px solid rgba(40, 167, 69, 0.3);
+}
+
+.bio-message.error {
+ background-color: rgba(220, 53, 69, 0.15);
+ color: #ff6b6b;
+ border: 1px solid rgba(220, 53, 69, 0.3);
+}
+
+/* Read-only Bio */
+.bio-display {
+ color: var(--mode-text-color);
+ line-height: 1.6;
+}
+
+.bio-display p {
+ margin: 0;
+ white-space: pre-wrap;
}
-.no-matches {
+.bio-display p.empty-bio {
+ color: #666;
+ font-size: 0.85rem;
font-style: italic;
- color: var(--text-color-secondary);
- font-size: 1.1rem;
- padding: 10px 0;
+}
+
+/* ========== STATS SECTION ========== */
+/* Stats toggle uses SegmentedToggle component */
+
+.stats-section .section-content {
+ padding: 0.75rem 1rem 1rem;
+}
+
+/* Stats Grid - Overview */
+.stats-grid.overview {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.5rem;
+}
+
+.stat-tile {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.6rem 0.75rem;
+ background-color: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ transition: all 0.2s ease;
+ flex-wrap: wrap;
+}
+
+.stat-tile .stat-title {
width: 100%;
- text-align: center;
- margin-left: 0;
- min-height: 50px;
+ margin-top: 0.15rem;
+}
+
+.stat-tile:hover {
+ transform: translateY(-2px);
+ border-color: rgba(245, 128, 37, 0.2);
+}
+
+.stat-tile.primary {
+ border-left: 3px solid #F58025;
+}
+
+.stat-tile.accent {
+ border-left: 3px solid #3498DB;
+}
+
+.stat-tile.success {
+ border-left: 3px solid #2ECC71;
+}
+
+.stat-icon {
+ width: 28px;
+ height: 28px;
display: flex;
align-items: center;
justify-content: center;
+ background-color: rgba(245, 128, 37, 0.1);
+ border-radius: 6px;
+ flex-shrink: 0;
}
-.title-display.static-title {
- background-color: rgba(255, 255, 255, 0.05);
- border: 1px solid var(--hover-color);
+.stat-tile.primary .stat-icon { background-color: rgba(245, 128, 37, 0.1); }
+.stat-tile.accent .stat-icon { background-color: rgba(52, 152, 219, 0.1); }
+.stat-tile.success .stat-icon { background-color: rgba(46, 204, 113, 0.1); }
+
+.stat-icon .material-icons {
+ font-size: 16px;
+ color: #F58025;
+}
+
+.stat-tile.accent .stat-icon .material-icons { color: #3498DB; }
+.stat-tile.success .stat-icon .material-icons { color: #2ECC71; }
+
+.stat-data {
+ display: flex;
+ align-items: baseline;
+ gap: 0.15rem;
+}
+
+.stat-number {
+ font-size: 1.15rem;
+ font-weight: 700;
color: var(--mode-text-color);
- border-radius: 4px;
- padding: 0.5rem 0.8rem;
- font-size: 1.5rem;
- text-align: left;
- margin-bottom: 0.5rem;
- min-height: 40px;
+}
+
+.stat-unit {
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: #777;
+}
+
+.stat-title {
+ font-size: 0.6rem;
+ color: #777;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+/* Stats Grid - Detailed */
+.stats-grid.detailed {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.stat-row {
display: flex;
+ justify-content: space-between;
align-items: center;
- transition: border-color 0.2s ease;
+ padding: 0.5rem 0.75rem;
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 6px;
+ transition: background-color 0.15s ease;
}
-.title-display.static-title:hover {
- border-color: #F58025;
+.stat-row:hover {
+ background-color: rgba(0, 0, 0, 0.25);
+}
+
+.stat-row.highlight {
+ background-color: rgba(245, 128, 37, 0.1);
+ border: 1px solid rgba(245, 128, 37, 0.2);
+}
+
+.row-label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ color: #999;
}
-.displayed-title-name {
+.row-label .material-icons {
+ font-size: 14px;
+ color: #F58025;
+}
+
+.row-value {
+ font-size: 0.85rem;
font-weight: 600;
- color: var(--text-color-highlight);
+ color: var(--mode-text-color);
+}
+
+.stats-loading,
+.no-stats {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 2rem;
+ color: #888;
+ text-align: center;
}
-/* Title tooltip styles for profile modal */
-.displayed-title-name.title-with-tooltip {
+/* ========== MATCH HISTORY / TIMELINE ========== */
+.history-section {
+ height: fit-content;
+ max-height: 500px;
+ display: flex;
+ flex-direction: column;
+}
+
+.history-section .section-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem 1rem;
+}
+
+/* Custom scrollbar for history */
+.history-section .section-content::-webkit-scrollbar {
+ width: 6px;
+}
+
+.history-section .section-content::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 3px;
+}
+
+.history-section .section-content::-webkit-scrollbar-thumb {
+ background: #F58025;
+ border-radius: 3px;
+}
+
+.match-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
position: relative;
- cursor: help;
}
-.displayed-title-name.title-with-tooltip .title-tooltip {
- visibility: hidden;
- opacity: 0;
- position: fixed;
- background-color: rgba(20, 20, 20, 0.98);
- color: #fff;
- padding: 0.6rem 0.9rem;
+.timeline-item {
+ display: flex;
+ gap: 0.75rem;
+ position: relative;
+}
+
+.timeline-connector {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 20px;
+ flex-shrink: 0;
+}
+
+.connector-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.3);
+ border: 2px solid var(--secondary-color);
+ z-index: 1;
+}
+
+.timeline-item.gold .connector-dot { background-color: #FFD700; }
+.timeline-item.silver .connector-dot { background-color: #C0C0C0; }
+.timeline-item.bronze .connector-dot { background-color: #CD7F32; }
+
+.connector-line {
+ flex: 1;
+ width: 2px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.05) 100%);
+ min-height: 16px;
+}
+
+.match-card {
+ flex: 1;
+ background-color: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+ transition: all 0.2s ease;
+}
+
+.match-card:hover {
+ border-color: rgba(245, 128, 37, 0.3);
+ transform: translateX(4px);
+}
+
+.timeline-item.gold .match-card {
+ border-left: 3px solid #FFD700;
+}
+
+.timeline-item.silver .match-card {
+ border-left: 3px solid #C0C0C0;
+}
+
+.timeline-item.bronze .match-card {
+ border-left: 3px solid #CD7F32;
+}
+
+.match-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.match-type {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--mode-text-color);
+}
+
+.match-date {
+ font-size: 0.7rem;
+ color: #888;
+}
+
+.match-body {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.match-position {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 42px;
+ padding: 0.4rem;
+ background-color: rgba(255, 255, 255, 0.05);
border-radius: 6px;
- font-size: 0.85rem;
- max-width: 280px;
- text-align: left;
- z-index: 10001;
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
- border: 1px solid rgba(245, 128, 37, 0.4);
- pointer-events: none;
- transition: opacity 0.2s ease, visibility 0.2s ease;
- line-height: 1.4;
- white-space: normal;
- word-wrap: break-word;
}
-.displayed-title-name.title-with-tooltip:hover .title-tooltip {
- visibility: visible;
- opacity: 1;
+.position-value {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #888;
}
-.no-title-display {
- font-style: italic;
- color: #888888;
+.match-position.gold .position-value {
+ color: #FFD700;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.4);
+}
+
+.match-position.silver .position-value {
+ color: #C0C0C0;
+ text-shadow: 0 0 10px rgba(192, 192, 192, 0.3);
+}
+
+.match-position.bronze .position-value {
+ color: #CD7F32;
+ text-shadow: 0 0 10px rgba(205, 127, 50, 0.3);
+}
+
+.match-metrics {
+ display: flex;
+ gap: 1rem;
+ flex: 1;
+}
+
+.metric {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.metric-value {
font-size: 1rem;
- width: 100%;
- padding: 2px 0;
+ font-weight: 600;
+ color: var(--mode-text-color);
}
-.read-only-bio-container {
- resize: none;
- border-radius: 15px;
- padding: 15px;
- width: 100%;
- min-height: 200px;
- background-color: var(--hover-color);
- color: #f8f9fa;
- border: 1px solid #444;
- font-family: inherit;
- font-size: 1.5rem;
- margin-bottom: 10px;
- box-sizing: border-box;
- overflow-y: auto;
+.metric-value.wpm {
+ color: #F58025;
}
-.read-only-bio-container .bio-text {
- margin: 0;
- text-align: left;
- white-space: pre-wrap;
+.metric-value.accuracy {
+ color: #2ECC71;
}
-.read-only-bio-container .bio-text:empty::before {
- content: 'This user hasn\'t written a bio yet.';
- color: #888888;
+.metric-label {
+ font-size: 0.6rem;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.match-footer {
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.match-category {
+ font-size: 0.75rem;
+ color: #888;
font-style: italic;
}
+
+.history-loading,
+.no-history {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ color: #888;
+ text-align: center;
+}
+
+.no-history .material-icons {
+ font-size: 40px;
+ color: #666;
+}
+
+.no-history p {
+ margin: 0;
+}
+
+/* ========== RESPONSIVE DESIGN ========== */
+@media (max-width: 900px) {
+ .profile-main {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-content {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .user-identity {
+ align-items: center;
+ }
+
+ .title-stats-row {
+ flex-direction: column;
+ margin-right: 0;
+ gap: 0.5rem;
+ }
+
+ .title-selector {
+ max-width: 100%;
+ }
+
+ .hero-quick-stats {
+ flex-wrap: wrap;
+ justify-content: center;
+ border-radius: 12px;
+ border-right: 1px solid rgba(245, 128, 37, 0.25);
+ }
+
+ .quick-stat-divider {
+ display: none;
+ }
+
+ .quick-stat {
+ padding: 0.4rem 0.75rem;
+ }
+}
+
+@media (max-width: 600px) {
+ .profile-container {
+ border-radius: 16px;
+ }
+
+ .profile-hero {
+ padding: 1.25rem 1rem 1rem;
+ border-radius: 16px 16px 0 0;
+ }
+
+ .avatar-container {
+ width: 90px;
+ height: 90px;
+ }
+
+ .username {
+ font-size: 1.4rem;
+ }
+
+ .quick-stat .stat-value {
+ font-size: 1.1rem;
+ }
+
+ .quick-stat .stat-label {
+ font-size: 0.55rem;
+ }
+
+ .profile-main {
+ padding: 0.75rem;
+ }
+
+ .stats-grid.overview {
+ grid-template-columns: 1fr;
+ }
+
+ .match-body {
+ flex-wrap: wrap;
+ }
+
+ .modal-header h3 {
+ font-size: 1rem;
+ }
+}
diff --git a/client/src/components/ProfileModal.jsx b/client/src/components/ProfileModal.jsx
index d856e03b..b0ae6f60 100644
--- a/client/src/components/ProfileModal.jsx
+++ b/client/src/components/ProfileModal.jsx
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import './ProfileModal.css';
import defaultProfileImage from '../assets/icons/default-profile.svg';
-import { createPortal } from 'react-dom';
+import SegmentedToggle from './SegmentedToggle';
function ProfileModal({ isOpen, onClose, netid }) {
const { user, loading, setUser, fetchUserProfile } = useAuth();
@@ -19,33 +19,76 @@ function ProfileModal({ isOpen, onClose, netid }) {
const fileInputRef = useRef(null);
const [detailedStats, setDetailedStats] = useState(null);
const [loadingStats, setLoadingStats] = useState(true);
- const [selectedTitle, setSelectedTitle] =useState('');
+ const [selectedTitle, setSelectedTitle] = useState('');
const [matchHistory, setMatchHistory] = useState([]);
const [loadingMatchHistory, setLoadingMatchHistory] = useState(true);
- const [userBadges, setUserBadges] = useState([]);
- const [loadingBadges, setLoadingBadges] = useState(false);
const [userTitles, setUserTitles] = useState([]);
const [loadingTitles, setLoadingTitles] = useState(false);
const [showTitleDropdown, setShowTitleDropdown] = useState(false);
- const [displayedBadges, setDisplayedBadges] = useState([]);
- const [showBadgeSelector, setShowBadgeSelector] = useState(false);
- const [maxBadges] = useState(5); // Maximum number of badges that can be displayed
const [allTitles, setAllTitles] = useState([]);
const [loadingAllTitles, setLoadingAllTitles] = useState(false);
-
+ const [activeStatsTab, setActiveStatsTab] = useState('overview'); // New state for stats tabs
+ const [dropdownStyle, setDropdownStyle] = useState({}); // For fixed positioning of title dropdown
+
const modalRef = useRef();
+ const titleButtonRef = useRef(null); // Ref for title button positioning
+ const titleDropdownRef = useRef(null); // Ref for title dropdown click-outside detection
const typingInputRef = document.querySelector('.typing-input-container input');
- const getBadgeEmoji = (key) => {
- switch (key) {
- case 'first_race': return '🏁';
- case 'novice': return '🥉';
- case 'intermediate': return '🥈';
- case 'advanced': return '🥇';
- case 'expert': return '👑';
- case 'fast': return '⚡';
- default: return '🏆';
+ // Rank tiers based on races completed - Princeton-themed names
+ const RANK_TIERS = [
+ { min: 0, max: 10, tier: 'freshman', label: 'Freshman', color: '#95A5A6' },
+ { min: 10, max: 50, tier: 'sophomore', label: 'Sophomore', color: '#3498DB' },
+ { min: 50, max: 100, tier: 'junior', label: 'Junior', color: '#2ECC71' },
+ { min: 100, max: 250, tier: 'senior', label: 'Senior', color: '#F58025' },
+ { min: 250, max: 500, tier: 'gradstudent', label: 'Grad Student', color: '#9B59B6' },
+ { min: 500, max: 1000, tier: 'supersenior', label: 'Super Senior', color: '#E74C3C' },
+ { min: 1000, max: Infinity, tier: 'einstein', label: 'Einstein', color: '#FFD700' },
+ ];
+
+ // Determine user's rank tier based on races completed
+ const getRankTier = (racesCompleted) => {
+ const races = racesCompleted || 0;
+ for (const tier of RANK_TIERS) {
+ if (races >= tier.min && races < tier.max) {
+ return tier;
+ }
+ }
+ return RANK_TIERS[RANK_TIERS.length - 1]; // Return highest tier if somehow exceeded
+ };
+
+ // Calculate progress to next rank
+ const getRankProgress = (racesCompleted) => {
+ const races = racesCompleted || 0;
+ const currentTier = getRankTier(races);
+ const currentIndex = RANK_TIERS.findIndex(t => t.tier === currentTier.tier);
+ const nextTier = currentIndex < RANK_TIERS.length - 1 ? RANK_TIERS[currentIndex + 1] : null;
+
+ if (!nextTier) {
+ // At max rank
+ return {
+ currentTier,
+ nextTier: null,
+ progress: 100,
+ racesNeeded: 0,
+ racesInTier: races - currentTier.min,
+ tierRange: 0,
+ };
}
+
+ const racesInTier = races - currentTier.min;
+ const tierRange = nextTier.min - currentTier.min;
+ const progress = (racesInTier / tierRange) * 100;
+ const racesNeeded = nextTier.min - races;
+
+ return {
+ currentTier,
+ nextTier,
+ progress: Math.min(progress, 100),
+ racesNeeded,
+ racesInTier,
+ tierRange,
+ };
};
// Function to add cache busting parameter to image URL (this is so scuffed, even if it works pls refine ammaar)
@@ -119,11 +162,11 @@ function ProfileModal({ isOpen, onClose, netid }) {
} finally {
// If viewing self, loading depends on AuthContext, not this fetch
if (netid) {
- setLoadingProfile(false);
+ setLoadingProfile(false);
} else {
- // For self view, loading is finished when AuthContext `loading` is false
- // We set it true initially and rely on AuthContext state
- setLoadingProfile(loading); // Link to auth loading state
+ // For self view, loading is finished when AuthContext `loading` is false
+ // We set it true initially and rely on AuthContext state
+ setLoadingProfile(loading); // Link to auth loading state
}
}
};
@@ -210,7 +253,7 @@ function ProfileModal({ isOpen, onClose, netid }) {
}
else if (temp1 === '3') {
temp1 += 'rd';
- } else if (typeof temp1 === 'string'){
+ } else if (typeof temp1 === 'string') {
temp1 += 'th';
}
i['position'] = temp1; // Add suffix to position
@@ -233,98 +276,6 @@ function ProfileModal({ isOpen, onClose, netid }) {
fetchMatchHistory();
}, [isOpen, netid, user, timestamp]);
- // Fetch Badges
- useEffect(() => {
- if (!isOpen) return;
- const targetNetId = netid || user?.netid;
- if (!targetNetId) return;
-
- const isOwn = !netid || (user && netid === user.netid);
-
- const fetchUserBadges = async () => {
- try {
- setLoadingBadges(true);
- const url = isOwn ? '/api/user/badges' : `/api/user/${targetNetId}/badges`;
- const response = await fetch(url, { credentials: 'include' });
-
- const data = await response.json();
- // console.log('User badges:', data);
- setUserBadges(data || []);
- }
- catch (error) {
- console.error('Error fetching user badges:', error);
- setUserBadges([]);
- }
- finally {
- setLoadingBadges(false);
- }
- }
-
- fetchUserBadges();
- }, [isOpen, netid, user]);
-
- const toggleBadgeSelection = (badge) => {
- const isCurrentlySelected = displayedBadges.some(b => b.id === badge.id);
-
- if (isCurrentlySelected) {
- // Remove badge from selection
- setDisplayedBadges(displayedBadges.filter(b => b.id !== badge.id));
- } else if (displayedBadges.length < maxBadges) {
- // Add badge to selection if under max limit
- setDisplayedBadges([...displayedBadges, badge]);
- }
- };
-
- useEffect(() => {
- if (isOpen && userBadges?.length > 0) {
- const savedBadgeIds = JSON.parse(localStorage.getItem('displayedBadgeIds') || '[]');
- const badgeDisplayOrder = JSON.parse(localStorage.getItem('badgeDisplayOrder') || '[]');
-
- if (badgeDisplayOrder.length > 0) {
- // Use the stored order to display badges
- const orderedBadges = [];
-
- // First add badges in their saved order
- badgeDisplayOrder.forEach(item => {
- const badge = userBadges.find(b => b.id.toString() === item.id);
- if (badge) {
- orderedBadges.push(badge);
- }
- });
-
- // Set the ordered badges
- setDisplayedBadges(orderedBadges.slice(0, maxBadges));
- } else {
- // Fall back to the old method if no order is saved
- const badgesToDisplay = userBadges.filter(badge =>
- savedBadgeIds.includes(badge.id.toString())
- );
-
- setDisplayedBadges(badgesToDisplay.slice(0, maxBadges));
- }
- }
-}, [isOpen, userBadges, maxBadges]);
-
- const saveBadgeSelections = () => {
- const badgeIds = displayedBadges.map(badge => badge.id.toString());
- const orderedBadges = displayedBadges.map((badge, index) => ({
- id: badge.id.toString(),
- order: index
- }));
-
- localStorage.setItem('displayedBadgeIds', JSON.stringify(badgeIds));
- localStorage.setItem('badgeDisplayOrder', JSON.stringify(orderedBadges));
-
- // Persist selections to server
- fetch('/api/profile/badges', {
- method: 'PUT',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ badgeIds })
- }).catch(err => console.error('Error saving badge selections:', err));
- setShowBadgeSelector(false);
- };
-
const handleTitleClick = () => {
setShowTitleDropdown(!showTitleDropdown);
};
@@ -334,15 +285,15 @@ function ProfileModal({ isOpen, onClose, netid }) {
try {
// Update the local state immediately for a smooth transition
setSelectedTitle(titleId);
-
+
// Update the userTitles array to reflect the new equipped status
- setUserTitles(prevTitles =>
+ setUserTitles(prevTitles =>
prevTitles.map(title => ({
...title,
is_equipped: String(title.id) === String(titleId)
}))
);
-
+
// Send the API request after updating local state
const response = await fetch('/api/profile/title', {
method: 'PUT',
@@ -350,7 +301,7 @@ function ProfileModal({ isOpen, onClose, netid }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titleId }),
});
-
+
if (response.ok) {
// Update the user context in the background without forcing a refresh
const userData = await fetchUserProfile();
@@ -404,6 +355,45 @@ function ProfileModal({ isOpen, onClose, netid }) {
}
}, [isOpen, userTitles]);
+ // Calculate dropdown position when it opens (fixes z-index stacking context issue)
+ useEffect(() => {
+ if (showTitleDropdown && titleButtonRef.current) {
+ const rect = titleButtonRef.current.getBoundingClientRect();
+ setDropdownStyle({
+ position: 'fixed',
+ top: rect.bottom + 8,
+ left: rect.left,
+ width: rect.width,
+ minWidth: 280,
+ });
+ }
+ }, [showTitleDropdown]);
+
+ // Close title dropdown when clicking outside
+ useEffect(() => {
+ if (!showTitleDropdown) return;
+
+ const handleClickOutside = (event) => {
+ // Check if click is outside both the button and the dropdown
+ const isOutsideButton = titleButtonRef.current && !titleButtonRef.current.contains(event.target);
+ const isOutsideDropdown = titleDropdownRef.current && !titleDropdownRef.current.contains(event.target);
+
+ if (isOutsideButton && isOutsideDropdown) {
+ setShowTitleDropdown(false);
+ }
+ };
+
+ // Add listener with a small delay to avoid immediate closing
+ const timeoutId = setTimeout(() => {
+ document.addEventListener('mousedown', handleClickOutside);
+ }, 0);
+
+ return () => {
+ clearTimeout(timeoutId);
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [showTitleDropdown]);
+
// Parse numeric values to check if ints
const parseNumericValue = (value) => {
if (value === null || value === undefined) return 0;
@@ -470,9 +460,9 @@ function ProfileModal({ isOpen, onClose, netid }) {
const handleAvatarClick = () => {
if (fileInputRef.current) {
- fileInputRef.current.click();
+ fileInputRef.current.click();
} else {
- console.error('[handleAvatarClick] fileInputRef.current is null or undefined!');
+ console.error('[handleAvatarClick] fileInputRef.current is null or undefined!');
}
};
@@ -506,7 +496,7 @@ function ProfileModal({ isOpen, onClose, netid }) {
const file = e.target.files[0];
if (!file) {
- return;
+ return;
}
// Validate file type
@@ -544,16 +534,16 @@ function ProfileModal({ isOpen, onClose, netid }) {
const data = await response.json();
if (!data.user || !data.user.avatar_url) {
- console.error('[handleFileChange] Server response missing user or avatar_url:', data);
- throw new Error('Invalid response data from server.');
+ console.error('[handleFileChange] Server response missing user or avatar_url:', data);
+ throw new Error('Invalid response data from server.');
}
const newAvatarUrl = data.user.avatar_url;
// Update user state with new avatar URL from server
setUser(prevUser => {
- const updatedUser = { ...prevUser, avatar_url: newAvatarUrl };
- return updatedUser;
+ const updatedUser = { ...prevUser, avatar_url: newAvatarUrl };
+ return updatedUser;
});
// Make sure window.user is updated
@@ -578,7 +568,7 @@ function ProfileModal({ isOpen, onClose, netid }) {
} catch (error) {
console.error('[handleFileChange] ERROR caught during avatar upload:', error);
if (user && user.avatar_url !== localImageUrl) {
- setUser(prevUser => ({ ...prevUser, avatar_url: user.avatar_url }));
+ setUser(prevUser => ({ ...prevUser, avatar_url: user.avatar_url }));
}
// Revoke the temp URL
@@ -619,15 +609,30 @@ function ProfileModal({ isOpen, onClose, netid }) {
fetchAllTitles();
}, [isOpen]);
+ // Get user rank info for visual styling (based on races completed)
+ const userRank = getRankTier(parseNumericValue(displayUser?.races_completed));
+ const rankProgress = getRankProgress(parseNumericValue(displayUser?.races_completed));
+
+ // Calculate completion rate
+ const completionRate = detailedStats && detailedStats.sessions_started > 0
+ ? (detailedStats.sessions_completed / detailedStats.sessions_started * 100).toFixed(1)
+ : 0;
+
+ // Get equipped title for display
+ const equippedTitle = userTitles.find(t => String(t.id) === String(selectedTitle));
+
// Loading state check (consider both auth loading and profile loading)
if ((isOwnProfile && loading) || (!isOwnProfile && loadingProfile)) {
- return (
-
-
- Loading profile...
-
+ return (
+
+ );
}
// If modal is closed, render nothing (after all hooks)
@@ -638,21 +643,24 @@ function ProfileModal({ isOpen, onClose, netid }) {
return (
-
-
-
-
-
Profile
+ {/* Close Button */}
+
+
+ {/* ==================== HERO SECTION ==================== */}
+
-
-
-
-
-
+
+ {/* Avatar Section */}
+
+
+
+
{isOwnProfile ? (
<>
{/* Hover hint overlay for discoverability */}
-
Upload Profile Picture
+
+ photo_camera
+ Change Photo
+
{
- const target = e.target;
- if (isOwnProfile) {
- handleFileChange(e); // Call original handler
- }
- target.value = null;
+ const target = e.target;
+ if (isOwnProfile) {
+ handleFileChange(e); // Call original handler
+ }
+ target.value = null;
}}
style={{ display: 'none' }}
accept="image/jpeg, image/png, image/gif, image/webp"
/>
- {isUploading &&
Uploading...
}
- {uploadError &&
{uploadError}
}
- {uploadSuccess &&
{uploadSuccess}
}
+ {isUploading && (
+
+ )}
>
) : (
// Read-only image for other users

)}
+ {/* Rank badge positioned below avatar */}
+
+ {userRank.label}
+
+
-
-
-
{displayUser?.netid || 'Guest'}
-
-
- {/* Title Section - Conditional */}
- {isOwnProfile ? (
-
-
- {selectedTitle ?
- userTitles.find(t => String(t.id) === String(selectedTitle))?.name || 'Select a title...'
- : 'Select a title...'}
- ▼
-
- {showTitleDropdown && (
-
- {/* Add Deselect Option */}
-
selectTitle(null)}
- >
- Deselect Title
-
- {loadingAllTitles ? (
-
Loading titles...
- ) : allTitles && allTitles.length > 0 ? (
- allTitles.map(title => {
- const isUnlocked = userTitles.some(t => t.id === title.id);
- return (
-
isUnlocked && selectTitle(title.id)}
- >
- {title.name}
-
- - {title.description || 'No description available'}
-
- {!isUnlocked && (
-
- lock
- Locked
-
- )}
-
- );
- })
- ) : (
-
No titles available
- )}
-
- )}
-
- ) : (
- // Read-only title display for others
-
- {loadingTitles ? (
- Loading title...
- ) : displayUser && displayUser.selected_title_id && userTitles.find(t => String(t.id) === String(displayUser.selected_title_id)) ? (
- // Display the equipped title if available
- (() => {
- const equippedTitle = userTitles.find(t => String(t.id) === String(displayUser.selected_title_id));
- return (
- {
- const tooltip = e.currentTarget.querySelector('.title-tooltip');
- if (tooltip) {
- const rect = e.currentTarget.getBoundingClientRect();
- tooltip.style.top = `${rect.bottom + 8}px`;
- tooltip.style.left = `${rect.left}px`;
- }
- }}
- >
- {equippedTitle.name}
- {equippedTitle.description && (
- {equippedTitle.description}
- )}
-
- );
- })()
- ) : userTitles.find(t => t.is_equipped) ? (
- // Alternatively check for is_equipped flag from the API response
- (() => {
- const equippedTitle = userTitles.find(t => t.is_equipped);
- return (
- {
- const tooltip = e.currentTarget.querySelector('.title-tooltip');
- if (tooltip) {
- const rect = e.currentTarget.getBoundingClientRect();
- tooltip.style.top = `${rect.bottom + 8}px`;
- tooltip.style.left = `${rect.left}px`;
- }
- }}
- >
- {equippedTitle.name}
- {equippedTitle.description && (
- {equippedTitle.description}
- )}
-
- );
- })()
- ) : (
- // Display message if no title is equipped
- User has no title selected
- )}
-
- )}
+ {/* Upload Messages */}
+ {uploadError &&
{uploadError}
}
+ {uploadSuccess &&
{uploadSuccess}
}
+
-
-
Badges
+ {/* User Identity */}
+
+
{displayUser?.netid || 'Guest'}
-
- {/* Show selected badges for self, all badges for others */}
- {(isOwnProfile ? displayedBadges : userBadges).map((badge, index) => (
+ {/* Title + Quick Stats Row */}
+
+ {/* Title Section - Conditional */}
+ {isOwnProfile ? (
+
+
+
+ {showTitleDropdown && (
+
+
Choose Your Title
+ {/* Add Deselect Option */}
setShowBadgeSelector(true) : undefined}
- style={!isOwnProfile ? { cursor: 'default' } : {}}
+ className="title-option deselect"
+ onClick={() => selectTitle(null)}
>
- {badge.icon_url ? (
-

- ) : (
-
{getBadgeEmoji(badge.key)}
- )}
-
{badge.name}
+
No Title
- ))}
-
- {/* Display message if viewing other profile with no badges */}
- {!isOwnProfile && userBadges.length === 0 && !loadingBadges && (
-
No badges earned yet.
- )}
+ {loadingAllTitles ? (
+
Loading titles...
+ ) : allTitles && allTitles.length > 0 ? (
+ allTitles.map(title => {
+ const isUnlocked = userTitles.some(t => t.id === title.id);
+ const isSelected = String(title.id) === String(selectedTitle);
+ return (
+
isUnlocked && selectTitle(title.id)}
+ >
+
+ {title.name}
+ {title.description || 'No description available'}
+
+ {!isUnlocked && (
+
lock
+ )}
+ {isSelected && isUnlocked && (
+
check
+ )}
+
+ );
+ })
+ ) : (
+
No titles available
+ )}
+
+ )}
+
+ ) : (
+ // Read-only title display for others
+
+ {loadingTitles ? (
+ Loading...
+ ) : displayUser && displayUser.selected_title_id && userTitles.find(t => String(t.id) === String(displayUser.selected_title_id)) ? (
+ // Display the equipped title if available
+ String(t.id) === String(displayUser.selected_title_id))?.description}
+ >
+ {userTitles.find(t => String(t.id) === String(displayUser.selected_title_id))?.name}
+
+ ) : userTitles.find(t => t.is_equipped) ? (
+ // Alternatively check for is_equipped flag from the API response
+ t.is_equipped)?.description}
+ >
+ {userTitles.find(t => t.is_equipped)?.name}
+
+ ) : (
+ // Display message if no title is equipped
+ No title equipped
+ )}
+
+ )}
- {/* Only show placeholder add badges if own profile */}
- {isOwnProfile && Array.from({ length: maxBadges - displayedBadges.length }, (_, i) => (
-
setShowBadgeSelector(true)}
- >
- +
-
- ))}
-
+ {/* Quick Stats in Hero */}
+
+
+ {parseNumericValue(displayUser?.avg_wpm).toFixed(0)}
+ AVG WPM
+
+
+
+ {parseNumericValue(displayUser?.fastest_wpm).toFixed(0)}
+ BEST WPM
+
+
+
+ {parseNumericValue(displayUser?.avg_accuracy).toFixed(0)}%
+ ACCURACY
-
+ {/* Rank Progress Bar */}
+
+
+
+ {rankProgress.currentTier.label}
+
+ {rankProgress.nextTier && (
+ <>
+ →
+
+ {rankProgress.nextTier.label}
+
+ >
+ )}
+
+
+
+ {rankProgress.nextTier ? (
+ {rankProgress.racesNeeded} races to {rankProgress.nextTier.label}
+ ) : (
+ Max rank achieved!
+ )}
+
+
+
+
+
+
+ {/* ==================== MAIN CONTENT GRID ==================== */}
+
+ {/* Left Column */}
+
+ {/* Bio Section */}
+
+
+
edit_note About
+
+
{isOwnProfile ? (
- <>
+
-
+ onChange={handleBioChange}
+ maxLength={500}
+ />
+
+ {bio.length}/500
- {bioMessage && {bioMessage}}
- >
+ {bioMessage && (
+
+ {bioMessage}
+
+ )}
+
) : (
// Read-only bio for others
-
-
{displayUser?.bio || ''}
+
+
+ {displayUser?.bio || 'This user hasn\'t written a bio yet.'}
+
)}
-
+
+
+ {/* Detailed Stats Section */}
+
+
+
insights Statistics
+
+
+
+ {activeStatsTab === 'overview' ? (
+
+
+
+ speed
+
+
+ {parseNumericValue(displayUser?.avg_wpm).toFixed(1)}
+ WPM
+
+
Average Speed
+
-
-
Match History
-
- {loadingMatchHistory ? (
-
Loading match history...
- ) : matchHistory.length === 0 ? (
-
No recent race history available.
- ) : (
-
- {matchHistory.map((match, index) => {
- // Determine position class
- let positionClass = '';
- if (match.position === '1st') positionClass = 'first-place';
- else if (match.position === '2nd') positionClass = 'second-place';
- else if (match.position === '3rd') positionClass = 'third-place';
-
- return (
-
-
- {new Date(match.created_at).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric'
- })}
+
+
+ bolt
+
+
+ {parseNumericValue(displayUser?.fastest_wpm).toFixed(1)}
+ WPM
+
+
Top Speed
+
+
+
+
+ check_circle
+
+
+ {parseNumericValue(displayUser?.avg_accuracy).toFixed(1)}
+ %
+
+
Accuracy
+
+
+
+
+ flag
+
+
+ {formatNumber(parseNumericValue(displayUser?.races_completed) || 0)}
+
+
Races Completed
+
+
+ ) : (
+
+ {loadingStats ? (
+
+ ) : detailedStats ? (
+ <>
+
+
+ play_arrow
+ Tests Started
+
+ {formatNumber(detailedStats.sessions_started)}
+
+
+
+ done_all
+ Tests Completed
+
+ {formatNumber(detailedStats.sessions_completed)}
+
+
+
+ keyboard
+ Words Typed
+
+ {formatNumber(detailedStats.words_typed)}
+
+
+
+ pie_chart
+ Completion Rate
+
+ {completionRate}%
-
- {/* Position Column (now first) */}
-
-
{match.position || '-'}
-
Position
+ >
+ ) : (
+
No detailed stats available
+ )}
+
+ )}
+
+
+
+
+ {/* Right Column - Match History */}
+
+
+
+
history Recent Matches
+
+
+ {loadingMatchHistory ? (
+
+ ) : matchHistory.length === 0 ? (
+
+
sports_esports
+
No matches yet. Start typing!
+
+ ) : (
+
+ {matchHistory.map((match, index) => {
+ // Determine position class
+ let positionClass = '';
+ if (match.position === '1st') positionClass = 'gold';
+ else if (match.position === '2nd') positionClass = 'silver';
+ else if (match.position === '3rd') positionClass = 'bronze';
+
+ return (
+
+
+
+ {index < matchHistory.length - 1 &&
}
- {/* Details Column (Type/Category + Stats) */}
-
-
-
- {match.lobby_type}
+
+
+ {match.lobby_type}
+
+ {new Date(match.created_at).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric'
+ })}
+
+
+
+
+ {/* Position Column (now first) */}
+
+ {match.position || '-'}
-
- {match.source || match.category || "Race"}
+
+ {/* Details Column (Type/Category + Stats) */}
+
+
+ {parseFloat(match.wpm).toFixed(0)}
+ WPM
+
+
+ {parseFloat(match.accuracy).toFixed(0)}%
+ ACC
+
-
- {parseFloat(match.wpm).toFixed(0)} WPM
- {parseFloat(match.accuracy).toFixed(0)}% Acc
-
+ {(match.source || match.category) && (
+
+ {match.source || match.category}
+
+ )}
-
- );
- })}
-
- )}
-
-
+ );
+ })}
+
+ )}
+
+
-
- {/* We may want to make stats be dynamic (i.e. golden color) if they're exceptional */}
-
- {/* Adjust title based on viewed user */}
-
{isOwnProfile ? 'Your' : `${displayUser?.netid || 'User'}'s`} Stats
- {!displayUser ? (
-
No stats available
- ) : (
-
-
-
Races Completed
-
{parseNumericValue(displayUser.races_completed) || 0}
-
-
-
Average WPM
-
{parseNumericValue(displayUser.avg_wpm).toFixed(2)}
-
-
-
Average Accuracy
-
{parseNumericValue(displayUser.avg_accuracy).toFixed(2)}%
-
-
-
Fastest Speed
-
{parseNumericValue(displayUser.fastest_wpm).toFixed(2)} WPM
-
-
- )}
-
- {loadingStats ? (
-
Loading detailed stats...
- ) : !detailedStats && (isOwnProfile || profileUser) ? (
-
No detailed stats available
- ) : detailedStats ? (
-
-
-
Total Tests Started
-
{formatNumber(detailedStats.sessions_started)}
-
-
-
Sessions Completed
-
{formatNumber(detailedStats.sessions_completed)}
-
-
-
Total Words Typed
-
{formatNumber(detailedStats.words_typed)}
-
-
-
Completion Rate
-
{detailedStats.sessions_started > 0
- ? (detailedStats.sessions_completed / detailedStats.sessions_started * 100).toFixed(1)
- : 0}%
-
-
- ) : null}
-
-
- {showBadgeSelector && (
-
e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- >
-
-
Select Badges to Display
-
- {loadingBadges ? (
-
Loading badges...
- ) : userBadges.length > 0 ? (
- userBadges.map(badge => (
-
b.id === badge.id) ? 'selected' : ''}`}
- onClick={() => toggleBadgeSelection(badge)}
- >
- {badge.icon_url ? (
-

- ) : (
-
{getBadgeEmoji(badge.key)}
- )}
-
- {badge.name}
- {badge.description}
-
-
- ))
- ) : (
-
No badges earned yet. Complete races to earn badges!
- )}
-
-
-
-
-
-
-
- )}
);
}
diff --git a/client/src/components/Results.css b/client/src/components/Results.css
index 70865885..a7e457ca 100644
--- a/client/src/components/Results.css
+++ b/client/src/components/Results.css
@@ -1,14 +1,38 @@
+/* ============================================
+ Results Page - Enhanced Celebration UI
+ ============================================ */
+
.results-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
- background-color: var(--secondary-color);
- border-radius: 8px;
+ background: linear-gradient(145deg, rgba(30, 30, 30, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%);
+ border-radius: 16px;
min-height: 0;
width: 100%;
position: relative;
top: 1vh;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+ animation: fade-up 0.5s ease-out;
+}
+
+/* Subtle particle background */
+.results-container::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ radial-gradient(circle at 15% 20%, rgba(245, 128, 37, 0.03) 0%, transparent 25%),
+ radial-gradient(circle at 85% 30%, rgba(245, 128, 37, 0.025) 0%, transparent 20%),
+ radial-gradient(circle at 50% 80%, rgba(245, 128, 37, 0.02) 0%, transparent 30%);
+ border-radius: 16px;
+ pointer-events: none;
+ z-index: 0;
}
/* Title badges for winners and results */
/* Winner title styling: single selected title */
@@ -52,7 +76,7 @@
font-size: 1.75rem;
}
-/* Winner Showcase Styles */
+/* Winner Showcase Styles - Enhanced Celebration */
.winner-showcase {
display: flex;
flex-direction: row;
@@ -60,12 +84,49 @@
justify-content: flex-start;
margin-bottom: 2rem;
padding: 2rem;
- background-color: rgba(245, 128, 37, 0.1);
- border-radius: 12px;
+ background: linear-gradient(135deg, rgba(245, 128, 37, 0.12) 0%, rgba(245, 128, 37, 0.06) 50%, rgba(255, 155, 82, 0.08) 100%);
+ border-radius: 20px;
width: 100%;
position: relative;
- border: 1px solid rgba(245, 128, 37, 0.3);
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(245, 128, 37, 0.35);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.25),
+ 0 0 40px rgba(245, 128, 37, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
+ animation: fade-up 0.6s ease-out 0.1s both;
+ overflow: hidden;
+ z-index: 1;
+}
+
+/* Celebratory glow effect */
+.winner-showcase::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: radial-gradient(circle at center, rgba(245, 128, 37, 0.15) 0%, transparent 40%);
+ animation: pulse-glow 3s ease-in-out infinite;
+ pointer-events: none;
+}
+
+/* Shimmer effect across the showcase */
+.winner-showcase::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.08),
+ transparent
+ );
+ transform: skewX(-20deg);
+ animation: shimmer 4s ease-in-out infinite;
}
@media (max-width: 768px) {
@@ -114,15 +175,27 @@
border: 4px solid #F58025;
margin-right: 2.5rem;
flex-shrink: 0;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+ box-shadow:
+ 0 0 30px rgba(245, 128, 37, 0.4),
+ 0 0 60px rgba(245, 128, 37, 0.2),
+ 0 8px 25px rgba(0, 0, 0, 0.4);
background-color: #1a1a1a;
cursor: pointer;
- transition: transform 0.2s ease, border-color 0.2s ease;
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
+ box-shadow 0.3s ease,
+ border-color 0.3s ease;
+ position: relative;
+ z-index: 2;
+ animation: float 4s ease-in-out infinite;
}
.winner-avatar:hover {
- transform: scale(1.05);
+ transform: scale(1.08);
border-color: #ff9d5c;
+ box-shadow:
+ 0 0 40px rgba(245, 128, 37, 0.5),
+ 0 0 80px rgba(245, 128, 37, 0.25),
+ 0 12px 35px rgba(0, 0, 0, 0.5);
}
.winner-avatar img {
@@ -146,17 +219,30 @@
.winner-trophy {
font-size: 2.5rem;
margin-right: 1rem;
- color: #F58025;
+ color: #FFD700;
+ filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5));
+ animation: float 3s ease-in-out infinite;
+ position: relative;
+ z-index: 2;
}
.winner-trophy i {
font-size: 2.5rem;
+ background: linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FFD700 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
}
.winner-netid {
font-size: 1.8rem;
- font-weight: bold;
- color: #F58025;
+ font-weight: 700;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 50%, #FFD700 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ position: relative;
+ z-index: 2;
}
.winner-stats {
@@ -494,29 +580,54 @@
}
.back-btn {
- padding: 0.7rem 2rem;
- background-color: #F58025;
- color: white;
+ position: relative;
+ padding: 0.8rem 2.5rem;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 50%, #F58025 100%);
+ background-size: 200% 200%;
+ color: #161616;
border: none;
- border-radius: 8px;
+ border-radius: 12px;
cursor: pointer;
font-size: 1rem;
- font-weight: 600;
- transition: all 0.2s ease;
- box-shadow: 0 4px 12px rgba(245, 128, 37, 0.3);
+ font-weight: 700;
+ transition: transform 0.25s ease, box-shadow 0.25s ease, background-position 0.5s ease;
+ box-shadow:
+ 0 4px 20px rgba(245, 128, 37, 0.35),
+ 0 8px 32px rgba(0, 0, 0, 0.3);
margin-top: 0.85rem;
width: 200px;
+ overflow: hidden;
+ z-index: 1;
+}
+
+/* Shimmer effect */
+.back-btn::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.25),
+ transparent
+ );
+ transform: skewX(-20deg);
+ animation: shimmer 3s ease-in-out infinite;
}
.back-btn:hover {
- background-color: #ff9d5c;
- transform: translateY(-2px);
- box-shadow: 0 6px 14px rgba(245, 128, 37, 0.4);
+ transform: translateY(-3px) scale(1.02);
+ background-position: 100% 50%;
+ box-shadow:
+ 0 8px 30px rgba(245, 128, 37, 0.5),
+ 0 12px 40px rgba(0, 0, 0, 0.35);
}
.back-btn:active {
- transform: translateY(0);
- box-shadow: 0 2px 8px rgba(245, 128, 37, 0.3);
+ transform: translateY(-1px) scale(0.99);
}
/* Override for Back to Menu button */
diff --git a/client/src/components/SegmentedToggle.css b/client/src/components/SegmentedToggle.css
new file mode 100644
index 00000000..49c22d81
--- /dev/null
+++ b/client/src/components/SegmentedToggle.css
@@ -0,0 +1,102 @@
+/* SegmentedToggle Component Styles */
+.segmented-toggle {
+ --segments: 1;
+ --active-index: 0;
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 0.2rem;
+ overflow: hidden;
+ gap: 0;
+ min-width: 0;
+}
+
+.segmented-highlight {
+ position: absolute;
+ top: 0.2rem;
+ bottom: 0.2rem;
+ left: 0.2rem;
+ width: calc((100% - 0.4rem) / var(--segments));
+ border-radius: 9px;
+ background: linear-gradient(135deg, #F58025, #ff9b52);
+ box-shadow: 0 12px 28px rgba(245, 128, 37, 0.42);
+ transform: translateX(calc(var(--active-index) * 100%));
+ transition: transform 0.22s ease, width 0.22s ease;
+ z-index: 0;
+}
+
+.segmented-option {
+ flex: 1;
+ position: relative;
+ z-index: 1;
+ border: none;
+ background: none;
+ background-color: transparent;
+ color: rgba(255, 255, 255, 0.78);
+ font-weight: 600;
+ font-size: 0.95rem;
+ padding: 0.45rem 0.9rem;
+ border-radius: 9px;
+ cursor: pointer;
+ transition: color 0.24s ease, text-shadow 0.32s ease;
+}
+
+.segmented-option:hover,
+.segmented-option:focus {
+ background-color: transparent;
+ box-shadow: none;
+}
+
+.segmented-option .segmented-label {
+ position: relative;
+ display: inline-block;
+ color: inherit;
+ text-shadow: 0 0 0 rgba(0, 0, 0, 0);
+ transform: translateY(0);
+ transition: color 0.28s ease, text-shadow 0.36s ease, transform 0.36s ease;
+}
+
+.segmented-option.active {
+ color: #161616;
+}
+
+.segmented-option:not(.active):hover .segmented-label,
+.segmented-option:not(.active):focus-visible .segmented-label {
+ color: #ffe0bc;
+ text-shadow: 0 0 6px rgba(255, 168, 92, 0.45), 0 0 14px rgba(255, 168, 92, 0.35);
+ transform: translateY(-1px);
+}
+
+.segmented-option:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(255, 180, 98, 0.35);
+}
+
+.segmented-option.active:focus-visible,
+.segmented-option.active:hover {
+ color: #161616;
+}
+
+/* Compact variant for smaller contexts */
+.segmented-toggle.compact {
+ padding: 0.15rem;
+ border-radius: 8px;
+}
+
+.segmented-toggle.compact .segmented-highlight {
+ top: 0.15rem;
+ bottom: 0.15rem;
+ left: 0.15rem;
+ width: calc((100% - 0.3rem) / var(--segments));
+ border-radius: 6px;
+}
+
+.segmented-toggle.compact .segmented-option {
+ font-size: 0.8rem;
+ padding: 0.35rem 0.7rem;
+ border-radius: 6px;
+}
diff --git a/client/src/components/SegmentedToggle.jsx b/client/src/components/SegmentedToggle.jsx
new file mode 100644
index 00000000..64661b2f
--- /dev/null
+++ b/client/src/components/SegmentedToggle.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import './SegmentedToggle.css';
+
+function SegmentedToggle({
+ options,
+ value,
+ onChange,
+ className = '',
+ ariaLabel,
+}) {
+ const activeIndexRaw = options.findIndex(option => option.value === value);
+ const activeIndex = activeIndexRaw >= 0 ? activeIndexRaw : 0;
+ const total = options.length || 1;
+ const classes = ['segmented-toggle', className].filter(Boolean).join(' ');
+
+ const handleKeyDown = (event, index) => {
+ if (!options.length) return;
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ event.preventDefault();
+ const next = (index + 1) % total;
+ onChange(options[next].value);
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ event.preventDefault();
+ const prev = (index - 1 + total) % total;
+ onChange(options[prev].value);
+ }
+ };
+
+ return (
+
+
+ {options.map((option, index) => {
+ const isActive = option.value === value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+SegmentedToggle.propTypes = {
+ options: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ label: PropTypes.node.isRequired,
+ })).isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ onChange: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ ariaLabel: PropTypes.string,
+};
+
+export default SegmentedToggle;
diff --git a/client/src/components/StatsShowcase.css b/client/src/components/StatsShowcase.css
index 6a7d942a..2603553c 100644
--- a/client/src/components/StatsShowcase.css
+++ b/client/src/components/StatsShowcase.css
@@ -1,19 +1,29 @@
.stats-showcase {
width: 100%;
padding: 1.2rem;
- background-color: var(--secondary-color, #1e1e1e);
- border-radius: 8px;
- border: 1px solid var(--hover-color, #3a3a3a);
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
+ background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(26, 26, 26, 0.95) 100%);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-radius: 16px;
+ border: 1px solid rgba(245, 128, 37, 0.15);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
height: 100%;
display: flex;
flex-direction: column;
+ animation: fade-up 0.5s ease-out 0.3s both;
}
.stats-heading {
text-align: center;
- color: var(--princeton-orange, #F58025);
- font-weight: bold;
+ font-weight: 700;
+ font-size: 1.3rem;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 0.5rem;
}
.stats-container {
@@ -30,35 +40,80 @@
flex-direction: row;
align-items: center;
padding: 0.8rem 1rem;
- background-color: var(--stat-card-color);
- border-radius: 8px;
- border: 1px solid rgba(255, 255, 255, 0.05);
- transition: transform 0.2s ease, box-shadow 0.2s ease;
+ background: linear-gradient(135deg, rgba(42, 42, 42, 0.6) 0%, rgba(35, 35, 35, 0.8) 100%);
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease, background 0.25s ease;
gap: 1rem;
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, rgba(245, 128, 37, 0.05) 0%, transparent 50%);
+ opacity: 0;
+ transition: opacity 0.25s ease;
}
.stat-card:hover {
- transform: translateX(5px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ transform: translateX(8px);
+ border-color: rgba(245, 128, 37, 0.25);
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.35),
+ 0 0 20px rgba(245, 128, 37, 0.1);
+}
+
+.stat-card:hover::before {
+ opacity: 1;
}
.stat-icon {
- font-size: 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
flex-shrink: 0;
+ background: linear-gradient(135deg, rgba(245, 128, 37, 0.15) 0%, rgba(245, 128, 37, 0.08) 100%);
+ border-radius: 8px;
+ transition: transform 0.25s ease, background 0.25s ease;
+}
+
+.stat-icon i {
+ font-size: 1.1rem;
+ color: #F58025;
+}
+
+.stat-card:hover .stat-icon {
+ transform: scale(1.1);
+ background: linear-gradient(135deg, rgba(245, 128, 37, 0.25) 0%, rgba(245, 128, 37, 0.12) 100%);
}
.stat-value {
font-size: 1.5rem;
- font-weight: bold;
- color: var(--princeton-orange, #F58025);
+ font-weight: 700;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
margin-right: 0.5rem;
+ position: relative;
+ z-index: 1;
}
.stat-label {
font-size: 0.85rem;
color: var(--mode-text-color, #e0e0e0);
- opacity: 0.9;
+ opacity: 0.85;
margin-left: auto;
+ position: relative;
+ z-index: 1;
}
/* Loading and Error States */
diff --git a/client/src/components/StatsShowcase.jsx b/client/src/components/StatsShowcase.jsx
index f23f800f..011f346c 100644
--- a/client/src/components/StatsShowcase.jsx
+++ b/client/src/components/StatsShowcase.jsx
@@ -10,36 +10,36 @@ function StatsShowcase() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- // Define the stats structure with icons
+ // Define the stats structure with Bootstrap icons
const statsConfig = [
{
id: 'races',
label: 'Races Completed',
- icon: '🏁',
+ iconClass: 'bi bi-flag-fill',
dataKey: 'total_races'
},
{
id: 'sessions',
label: 'Tests Started',
- icon: '🚀',
+ iconClass: 'bi bi-play-circle-fill',
dataKey: 'total_sessions_started'
},
{
id: 'words',
label: 'Words Typed',
- icon: '📝',
+ iconClass: 'bi bi-fonts',
dataKey: 'total_words_typed'
},
{
id: 'wpm',
label: 'Avg. WPM',
- icon: '⚡',
+ iconClass: 'bi bi-lightning-fill',
dataKey: 'avg_wpm'
},
{
id: 'users',
label: 'Active Tigers',
- icon: '🐯',
+ iconClass: 'bi bi-people-fill',
dataKey: 'active_users'
}
];
@@ -104,7 +104,9 @@ function StatsShowcase() {
) : (
stats.map(stat => (
-
{stat.icon}
+
+
+
{stat.value}
{stat.label}
diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx
index c97c5ffb..e8eb5fe2 100644
--- a/client/src/components/Typing.jsx
+++ b/client/src/components/Typing.jsx
@@ -58,6 +58,14 @@ function Typing({
const tabActionInProgressRef = useRef(false);
const [displayedWpm, setDisplayedWpm] = useState(0);
const [capsLockEnabled, setCapsLockEnabled] = useState(false);
+
+ // Anti-cheat: Track untrusted event ratio for script detection
+ // Catches all synthetic events
+ const untrustedEventCount = useRef(0);
+ const totalEventCount = useRef(0);
+ const UNTRUSTED_THRESHOLD = 0.95; // Only flag if 95%+ events are untrusted
+ const MIN_EVENTS_FOR_CHECK = 15;
+
// Smooth glide cursor overlay
const cursorRef = useRef(null);
const currentCharRef = useRef(null);
@@ -244,6 +252,14 @@ function Typing({
};
}, []);
+ // Reset anti-cheat counters when a new race starts
+ useEffect(() => {
+ if (raceState.inProgress && raceState.startTime) {
+ untrustedEventCount.current = 0;
+ totalEventCount.current = 0;
+ }
+ }, [raceState.inProgress, raceState.startTime]);
+
// Gets latest typingState.position
const positionRef = useRef(typingState.position);
useEffect(() => {
@@ -625,16 +641,34 @@ function Typing({
};
}, [raceState.inProgress, raceState.startTime, raceState.completed, typingState.correctChars]); // Include typingState.correctChars
+ // Helper: Track untrusted events and check ratio for script detection
+ // Only called for forward progress (typing, not deleting) to avoid browser quirks
+ const trackAndCheckTrust = (nativeEvent) => {
+ totalEventCount.current++;
+
+ if (nativeEvent && nativeEvent.isTrusted === false) {
+ untrustedEventCount.current++;
+ }
+
+ // Flag after enough samples if almost all events are untrusted (works in all modes)
+ if (totalEventCount.current >= MIN_EVENTS_FOR_CHECK) {
+ const ratio = untrustedEventCount.current / totalEventCount.current;
+ if (ratio >= UNTRUSTED_THRESHOLD) {
+ flagSuspicious('scripted-input', {
+ ratio: ratio.toFixed(2),
+ untrusted: untrustedEventCount.current,
+ total: totalEventCount.current
+ });
+ }
+ }
+ };
+
const handleBeforeInputGuard = (e) => {
if (anticheatState.locked) {
e.preventDefault();
return;
}
- if (e.nativeEvent && e.nativeEvent.isTrusted === false) {
- e.preventDefault();
- flagSuspicious('synthetic-beforeinput', { inputType: e.nativeEvent.inputType });
- return;
- }
+ // Don't track beforeinput - some browsers have quirks with isTrusted for deletion events
markTrustedInteraction();
};
@@ -643,12 +677,7 @@ function Typing({
e.preventDefault();
return;
}
- const nativeEvent = e.nativeEvent || e;
- if (nativeEvent && nativeEvent.isTrusted === false) {
- e.preventDefault();
- flagSuspicious('synthetic-keydown', { key: e.key });
- return;
- }
+ // Don't track keydown - only track onChange for reliable script detection
markTrustedInteraction();
};
@@ -658,12 +687,6 @@ function Typing({
e.preventDefault();
return;
}
- const nativeEvent = e.nativeEvent;
- if (nativeEvent && nativeEvent.isTrusted === false) {
- e.preventDefault();
- flagSuspicious('synthetic-input-change', { length: e.target?.value?.length ?? 0 });
- return;
- }
markTrustedInteraction();
const newInput = e.target.value;
const text = raceState.snippet?.text || '';
@@ -673,6 +696,12 @@ function Typing({
const isMovingForward = newInput.length > input.length;
const isCorrectCharacter = newInput[newInput.length - 1] === text[newInput.length - 1];
+ // Track trust status only for forward progress (typing, not backspace)
+ // This avoids browser quirks with deletion events
+ if (isMovingForward) {
+ trackAndCheckTrust(e.nativeEvent);
+ }
+
// Play sound if typing correctly (moved before practice mode check)
if (isMovingForward && isCorrectCharacter) {
playKeySound();
diff --git a/client/src/index.css b/client/src/index.css
index 0badbc07..27878ed4 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -88,4 +88,88 @@
button:hover {
background-color: var(--primary-color-dark);
- }
\ No newline at end of file
+ }
+
+/* ============================================
+ Shared Animation Utilities
+ ============================================ */
+
+/* Animation timing variables */
+:root {
+ --anim-fast: 0.2s;
+ --anim-normal: 0.3s;
+ --anim-slow: 0.5s;
+ --anim-slower: 0.8s;
+
+ /* Glow colors */
+ --glow-orange: rgba(245, 128, 37, 0.5);
+ --glow-orange-strong: rgba(245, 128, 37, 0.7);
+ --glow-blue: rgba(52, 152, 219, 0.5);
+ --glow-purple: rgba(155, 89, 182, 0.5);
+ --glow-green: rgba(46, 204, 113, 0.5);
+ --glow-red: rgba(231, 76, 60, 0.5);
+ --glow-gold: rgba(255, 215, 0, 0.5);
+}
+
+/* Shimmer animation for buttons and highlights - subtle */
+@keyframes shimmer {
+ 0% { left: -100%; opacity: 0; }
+ 10% { opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { left: 200%; opacity: 0; }
+}
+
+/* Pulse glow animation - subtle breathing effect */
+@keyframes pulse-glow {
+ 0%, 100% {
+ box-shadow: 0 0 15px rgba(245, 128, 37, 0.25),
+ 0 0 30px rgba(245, 128, 37, 0.1);
+ }
+ 50% {
+ box-shadow: 0 0 20px rgba(245, 128, 37, 0.35),
+ 0 0 40px rgba(245, 128, 37, 0.15);
+ }
+}
+
+/* Subtle float animation */
+@keyframes float {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-6px); }
+}
+
+/* Gradient shift animation for backgrounds */
+@keyframes gradient-shift {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+/* Entrance fade-up animation */
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Staggered entrance helper */
+.stagger-1 { animation-delay: 0.1s; }
+.stagger-2 { animation-delay: 0.2s; }
+.stagger-3 { animation-delay: 0.3s; }
+.stagger-4 { animation-delay: 0.4s; }
+
+/* Counter animation */
+@keyframes count-up {
+ from { opacity: 0; transform: scale(0.5); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+/* Border glow animation */
+@keyframes border-glow {
+ 0%, 100% { border-color: rgba(245, 128, 37, 0.3); }
+ 50% { border-color: rgba(245, 128, 37, 0.6); }
+}
\ No newline at end of file
diff --git a/client/src/pages/Home.css b/client/src/pages/Home.css
index 9a9cdc1f..594d1ba8 100644
--- a/client/src/pages/Home.css
+++ b/client/src/pages/Home.css
@@ -1,12 +1,38 @@
+/* ============================================
+ Home Page - Enhanced Styling
+ ============================================ */
+
.home-page {
max-width: 1200px;
margin: 0 auto;
+ position: relative;
+}
+
+/* Subtle background particles - orange tones only */
+.home-page::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ radial-gradient(circle at 25% 25%, rgba(245, 128, 37, 0.025) 0%, transparent 25%),
+ radial-gradient(circle at 75% 35%, rgba(232, 168, 73, 0.02) 0%, transparent 20%),
+ radial-gradient(circle at 50% 75%, rgba(212, 105, 42, 0.018) 0%, transparent 25%),
+ radial-gradient(circle at 15% 65%, rgba(224, 123, 76, 0.015) 0%, transparent 22%);
+ animation: gradient-shift 25s ease infinite;
+ background-size: 200% 200%;
}
.home-container {
display: flex;
flex-direction: column;
- margin-top: 5%;
+ margin-top: 3%;
+ position: relative;
+ z-index: 1;
}
/* header stacks title + tagline centrally */
@@ -14,38 +40,45 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 0.75rem;
- padding-bottom: 1.5rem;
- border-bottom: 1px solid rgba(245, 128, 37, 0.3);
+ gap: 0.6rem;
+ padding-bottom: 1.25rem;
+ border-bottom: 1px solid rgba(245, 128, 37, 0.2);
+ animation: fade-up 0.5s ease-out;
}
.home-header h1 {
- color: #F58025;
- font-size: 2.5rem;
+ font-size: 2.25rem;
+ font-weight: 700;
margin: 0;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 50%, #FFD700 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ text-shadow: none;
}
.home-header .home-tagline {
- font-size: 1.15rem;
+ font-size: 1.1rem;
color: var(--text-color-secondary);
+ opacity: 0.9;
}
.modes-section {
- margin: 2rem 0;
+ margin: 1.5rem 0;
}
.modes-layout-section {
display: flex;
align-items: stretch;
max-width: 1000px;
- margin: 2rem auto;
- gap: 2rem;
+ margin: 1.75rem auto;
+ gap: 1.75rem;
}
.standard-modes-container {
flex: 2; /* Takes up 2/3 of the space */
display: flex;
- gap: 2rem;
+ gap: 1.75rem;
}
.standard-modes-container > * {
@@ -59,14 +92,14 @@
.private-modes-stack {
flex: 1; /* Takes up 1/3 of the space */
display: flex;
- gap: 2rem;
+ gap: 1.75rem;
}
.private-modes-stack .modes-container {
display: flex;
flex-direction: column;
- height: 100%;
- gap: 2rem;
+ height: 100%;
+ gap: 1.75rem;
}
.private-modes-stack .modes-container .mode-box {
diff --git a/client/src/pages/Landing.css b/client/src/pages/Landing.css
index c18cc618..14951da3 100644
--- a/client/src/pages/Landing.css
+++ b/client/src/pages/Landing.css
@@ -25,7 +25,7 @@ body {
}
.login-nav-button {
- background-color: var(--secondary-color, #1e1e1e);
+ background-color: var(--secondary-color, #1e1e1e);
border: 1px solid var(--mode-text-color, #e0e0e0);
border-radius: 4px;
margin-left: 1rem;
@@ -37,6 +37,27 @@ body {
color: var(--princeton-orange, #F58025);
}
+/* ============================================
+ Landing Page Hero Particles Background
+ ============================================ */
+.landing-container::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ radial-gradient(circle at 20% 30%, rgba(245, 128, 37, 0.03) 0%, transparent 25%),
+ radial-gradient(circle at 80% 20%, rgba(245, 128, 37, 0.025) 0%, transparent 20%),
+ radial-gradient(circle at 60% 70%, rgba(245, 128, 37, 0.02) 0%, transparent 30%),
+ radial-gradient(circle at 10% 80%, rgba(245, 128, 37, 0.015) 0%, transparent 25%);
+ animation: gradient-shift 20s ease infinite;
+ background-size: 200% 200%;
+}
+
/* General Landing Page Container - Takes full height after Navbar */
.landing-container {
@@ -83,36 +104,88 @@ body {
background-color: var(--primary-color, #1e1e1e);
margin-bottom: 2rem;
object-fit: contain;
- border: 5px solid var(--hover-color, #2a2a2a);
+ border: 3px solid rgba(245, 128, 37, 0.25);
+ box-shadow:
+ 0 0 20px rgba(245, 128, 37, 0.15),
+ 0 8px 32px rgba(0, 0, 0, 0.35);
+ transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
+}
+
+.landing-logo-large:hover {
+ transform: scale(1.02);
+ border-color: rgba(245, 128, 37, 0.4);
+ box-shadow:
+ 0 0 30px rgba(245, 128, 37, 0.25),
+ 0 12px 40px rgba(0, 0, 0, 0.4);
}
/* Style for the login button in the left column */
.login-button-left {
- background-color: var(--secondary-color, #1e1e1e);
- color: var(--mode-text-color, #e0e0e0);
- border: 1px solid var(--mode-text-color, #e0e0e0);
- padding: 0.8rem 2.5rem;
- font-size: 1.1rem;
- font-weight: 600;
- border-radius: 6px;
+ position: relative;
+ background: linear-gradient(135deg, #F58025 0%, #ff9b52 50%, #F58025 100%);
+ background-size: 200% 200%;
+ color: #161616;
+ border: none;
+ padding: 1rem 2.5rem;
+ font-size: 1.15rem;
+ font-weight: 700;
+ border-radius: 12px;
cursor: pointer;
- text-align: center;
- transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
- margin-bottom: 2.5rem;
- width: 70%;
- max-width: 220px; /* Adjust max-width */
+ text-align: center;
+ transition: transform 0.25s ease, box-shadow 0.25s ease, background-position 0.5s ease;
+ margin-bottom: 2.5rem;
+ width: 70%;
+ max-width: 220px;
+ box-shadow:
+ 0 4px 20px rgba(245, 128, 37, 0.35),
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ overflow: hidden;
+ z-index: 1;
+}
+
+/* Shimmer/shine effect - subtle and infrequent */
+.login-button-left::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.2),
+ transparent
+ );
+ transform: skewX(-20deg);
+ animation: shimmer 5s ease-in-out infinite;
+ animation-delay: 2s;
}
.login-button-left:hover {
- background-color: var(--princeton-orange, #F58025);
- border-color: var(--princeton-orange, #F58025);
- color: white;
+ transform: translateY(-3px) scale(1.02);
+ background-position: 100% 50%;
+ box-shadow:
+ 0 8px 30px rgba(245, 128, 37, 0.5),
+ 0 12px 40px rgba(0, 0, 0, 0.35),
+ inset 0 1px 0 rgba(255, 255, 255, 0.25);
+}
+
+.login-button-left:active {
+ transform: translateY(-1px) scale(0.99);
}
/* Disabled login state styling */
.login-button-left:disabled {
- opacity: 0.6;
+ opacity: 0.5;
cursor: not-allowed;
+ background: linear-gradient(135deg, #666 0%, #888 100%);
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+}
+
+.login-button-left:disabled::after {
+ display: none;
}
/* Combined Data Section */
@@ -163,25 +236,44 @@ body {
.landing-right-column h2 {
font-size: 2.5rem;
color: var(--mode-text-color, #e0e0e0);
- font-weight: 600;
- margin-bottom: 2rem;
+ font-weight: 700;
+ margin-bottom: 2rem;
display: flex;
align-items: center;
+ background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 50%, #F58025 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ animation: fade-up 0.6s ease-out;
}
/* Animated Text Area */
.animated-text-container {
- background-color: var(--secondary-color, #1e1e1e);
+ background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(26, 26, 26, 0.95) 100%);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
padding: 1.5rem 2rem;
- border-radius: 8px;
- border: 1px solid var(--hover-color, #3a3a3a);
- min-height: 100px;
+ border-radius: 16px;
+ border: 1px solid rgba(245, 128, 37, 0.15);
+ min-height: 100px;
width: 100%;
- margin-bottom: 2rem;
+ margin-bottom: 2rem;
display: flex;
- align-items: flex-start;
- box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
- overflow: hidden;
+ align-items: flex-start;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
+ overflow: hidden;
+ animation: fade-up 0.6s ease-out 0.2s both;
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
+}
+
+.animated-text-container:hover {
+ border-color: rgba(245, 128, 37, 0.3);
+ box-shadow:
+ 0 12px 40px rgba(0, 0, 0, 0.4),
+ 0 0 20px rgba(245, 128, 37, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.animated-text {
diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js
index 7ab906c3..6151ddcd 100644
--- a/server/controllers/socket-handlers.js
+++ b/server/controllers/socket-handlers.js
@@ -31,9 +31,9 @@ const playerAvatars = new Map(); // socketId -> avatar_url
// Anticheat thresholds and state
const suspiciousPlayers = new Map(); // socketId -> { reasons: [], locked: boolean }
-const MAX_PROGRESS_STEP = 20; // max characters allowed per progress update
-const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets
-const MAX_ALLOWED_WPM = 320; // anything above is flagged
+const MAX_PROGRESS_STEP = 35; // max characters allowed per progress update (increased to handle batched updates)
+const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets (unused, kept for reference)
+const MAX_ALLOWED_WPM = 350; // anything above is flagged
const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this
// Store host disconnect timers for private lobbies
@@ -1422,11 +1422,8 @@ const initialize = (io) => {
const currentHasError = typeof hasError === 'boolean' ? hasError : prevProgress.hasError === true;
const delta = position - prevPosition;
- if (delta < 0) {
- registerSuspicion('negative-progress', { prevPosition, position });
- return;
- }
-
+ // Allow negative progress - users legitimately delete chars with backspace/CMD+backspace
+ // Only block large forward spikes (paste attacks)
if (delta > MAX_PROGRESS_STEP) {
registerSuspicion('progress-spike', { prevPosition, position, delta });
return;
@@ -1435,11 +1432,8 @@ const initialize = (io) => {
const lastUpdateTs = lastProgressUpdate.get(socket.id) || 0;
const interval = now - lastUpdateTs;
- if (interval < MIN_PROGRESS_INTERVAL && !isCompleted) {
- registerSuspicion('progress-interval', { interval });
- return;
- }
-
+ // Throttle updates for performance, but don't flag as suspicious
+ // Script detection is handled client-side via isTrusted ratio checking
if (interval < PROGRESS_THROTTLE && !isCompleted) {
return;
}
@@ -1454,17 +1448,24 @@ const initialize = (io) => {
const raceStart = race.startTime || now;
const elapsedMs = now - raceStart;
- if (elapsedMs > 0) {
+
+ // Check for impossibly fast completion
+ // Scale minimum time by snippet length: at least 50ms per character (1200 CPM = 240 WPM max burst)
+ const minTimeForSnippet = Math.max(MIN_COMPLETION_TIME_MS, snippetLength * 50);
+ if (isCompleted && elapsedMs < minTimeForSnippet) {
+ registerSuspicion('completion-too-fast', { elapsedMs, snippetLength, minRequired: minTimeForSnippet });
+ return;
+ }
+
+ // Check WPM after 3 seconds to avoid false positives from early burst typing
+ const MIN_ELAPSED_FOR_WPM_CHECK = 3000;
+ if (elapsedMs > MIN_ELAPSED_FOR_WPM_CHECK) {
const elapsedMinutes = elapsedMs / 60000;
const computedWpm = elapsedMinutes > 0 ? (position / 5) / elapsedMinutes : 0;
if (computedWpm > MAX_ALLOWED_WPM) {
registerSuspicion('wpm-threshold', { computedWpm, position, elapsedMs });
return;
}
- if (isCompleted && snippetLength > 40 && elapsedMs < MIN_COMPLETION_TIME_MS) {
- registerSuspicion('completion-too-fast', { elapsedMs, snippetLength });
- return;
- }
}
const history = Array.isArray(prevProgress.history) ? prevProgress.history : [];