From fac64237b77b7c4eed9c1a7b8797027971d83b7d Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 19:02:28 -0500 Subject: [PATCH 1/5] first redesign of profile modal page --- client/src/components/ProfileModal.css | 1934 ++++++++++++++++-------- client/src/components/ProfileModal.jsx | 930 +++++++----- 2 files changed, 1823 insertions(+), 1041 deletions(-) diff --git a/client/src/components/ProfileModal.css b/client/src/components/ProfileModal.css index 1fe205c4..1b874825 100644 --- a/client/src/components/ProfileModal.css +++ b/client/src/components/ProfileModal.css @@ -1,295 +1,475 @@ +/* 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; + overflow-x: hidden; + position: relative; + animation: modalSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); } -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(30px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } -.loading-container { - width: 90vw; - margin: 2rem auto; - padding: 2rem; - text-align: center; - font-size: 1.2rem; - color: var(--secondary-color); +/* Custom scrollbar for profile container */ +.profile-container::-webkit-scrollbar { + width: 8px; } -.stats-loading { - text-align: center; - padding: 2rem; - color: var(--secondary-color); +.profile-container::-webkit-scrollbar-track { + background: transparent; + margin: 24px 0; } -/* alignment debugged with ai */ -.back-button-container { - padding-bottom: 2rem; +.profile-container::-webkit-scrollbar-thumb { + background: rgba(245, 128, 37, 0.6); + border-radius: 4px; +} + +.profile-container::-webkit-scrollbar-thumb:hover { + background: #F58025; +} + +/* 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; - flex-direction: row; align-items: center; + justify-content: center; + transition: all 0.2s ease; + z-index: 100; +} + +.profile-close-btn:hover { + background-color: rgba(245, 128, 37, 0.3); + border-color: #F58025; + transform: rotate(90deg); +} + +.profile-close-btn .material-icons { + font-size: 24px; +} + +/* Loading States */ +.profile-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.profile-loader { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--mode-text-color); +} + +.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; +} + +.loader-ring.small { + width: 30px; + height: 30px; + border-width: 2px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ========== HERO SECTION ========== */ +.profile-hero { position: relative; + padding: 2rem 2rem 1.5rem; + overflow: hidden; + border-radius: 24px 24px 0 0; +} + +/* Rank-based hero backgrounds */ +.profile-hero.legendary { + background: linear-gradient(135deg, #1a1400 0%, #2d2000 50%, #1a1400 100%); +} + +.profile-hero.master { + background: linear-gradient(135deg, #1a0f20 0%, #251530 50%, #1a0f20 100%); } -.back-button-profile { +.profile-hero.expert { + background: linear-gradient(135deg, #0f1a24 0%, #152535 50%, #0f1a24 100%); +} + +.profile-hero.advanced { + background: linear-gradient(135deg, #0f1a14 0%, #15251a 50%, #0f1a14 100%); +} + +.profile-hero.intermediate { + background: linear-gradient(135deg, #1a1208 0%, #2d1f0d 50%, #1a1208 100%); +} + +.profile-hero.beginner { + background: linear-gradient(135deg, #151515 0%, #1e1e1e 50%, #151515 100%); +} + +/* Hero Background Effects */ +.hero-background { position: absolute; + top: 0; left: 0; - background-color: transparent; - border: 1px solid #F58025; - color: #F58025; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - z-index: 1; - font-size: 1.5rem; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; } -.back-button-profile span { - margin-right: 0.5rem; +.hero-glow { + position: absolute; + width: 400px; + height: 400px; + border-radius: 50%; + filter: blur(100px); + opacity: 0.4; + top: -100px; + left: 50%; + transform: translateX(-50%); } -.back-button-profile:hover { - background-color: rgba(245, 128, 37, 0.1); +.profile-hero.legendary .hero-glow { + background: radial-gradient(circle, #FFD700 0%, transparent 70%); } -.profile-title { - flex: 1; - text-align: center; - color: var(--mode-text-color); +.profile-hero.master .hero-glow { + background: radial-gradient(circle, #9B59B6 0%, transparent 70%); } -.profile-title h2{ - font-size: 2rem; +.profile-hero.expert .hero-glow { + background: radial-gradient(circle, #3498DB 0%, transparent 70%); } -.profile-header { - text-align: center; - margin-bottom: 1.5rem; +.profile-hero.advanced .hero-glow { + background: radial-gradient(circle, #2ECC71 0%, transparent 70%); +} + +.profile-hero.intermediate .hero-glow { + background: radial-gradient(circle, #F58025 0%, transparent 70%); } +.profile-hero.beginner .hero-glow { + background: radial-gradient(circle, #95A5A6 0%, transparent 70%); +} -.profile-components { - display:flex; - flex-direction: row; +.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; +} + +@keyframes particleDrift { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +/* Hero Content Layout */ +.hero-content { + display: flex; + align-items: center; gap: 2rem; + position: relative; + z-index: 1; } -.profile-header-info { - display:flex; +/* ========== AVATAR SECTION ========== */ +.profile-avatar-section { + display: flex; flex-direction: column; - flex: 1 1 55%; - min-width: 0; + align-items: center; + gap: 0.75rem; } -.profile-page-info { - display: flex; - align-items: flex-start; - gap: 1.875rem; - padding-bottom: 1.5rem; +.avatar-container { + position: relative; + width: 130px; + height: 130px; } -.profile-page-info h2 { - color: var(--mode-text-color); - margin-bottom: 0.5rem; +.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; } -.profile-page-info p { - color: #f8f9fa; +.avatar-container.legendary .avatar-ring { --ring-color: #FFD700; opacity: 0.8; } +.avatar-container.master .avatar-ring { --ring-color: #9B59B6; } +.avatar-container.expert .avatar-ring { --ring-color: #3498DB; } +.avatar-container.advanced .avatar-ring { --ring-color: #2ECC71; } +.avatar-container.intermediate .avatar-ring { --ring-color: #F58025; } +.avatar-container.beginner .avatar-ring { --ring-color: #95A5A6; } + +@keyframes ringRotate { + to { transform: rotate(360deg); } } -.profile-page-image { - border-radius: 30px; - border: solid; - border-color: #f8f9fa; - width: 235px; - height: 235px; +.avatar-wrapper { position: relative; + width: 100%; + height: 100%; + border-radius: 50%; overflow: hidden; - cursor: pointer; - transition: all 0.3s ease; - flex-shrink: 0; -} - -.profile-page-image:hover { - border-color: #F58025; - opacity: 0.9; + border: 4px solid var(--secondary-color); + background-color: var(--hover-color); } -.profile-page-image input[type="image"] { +.avatar-image { width: 100%; height: 100%; object-fit: cover; cursor: pointer; + transition: transform 0.3s ease; } -.profile-page-image input[type="image"].uploading { +.avatar-image.uploading { opacity: 0.5; } -.avatar-hover-hint { +.avatar-wrapper:hover .avatar-image { + transform: scale(1.05); +} + +/* Avatar Edit Overlay */ +.avatar-edit-overlay { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; + right: 0; + bottom: 0; display: flex; + flex-direction: column; align-items: center; justify-content: center; - background-color: rgba(0, 0, 0, 0.45); - color: #ffffff; - font-weight: 700; - letter-spacing: 0.3px; + 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.15s ease-in-out; - text-align: center; - padding: 0 10px; - pointer-events: none; /* let clicks fall through to the image input */ + transition: opacity 0.2s ease; + pointer-events: none; } -.profile-page-image:hover .avatar-hover-hint { - opacity: 1; +.avatar-edit-overlay .material-icons { + font-size: 28px; } -.static-avatar { - width: 100%; - height: 100%; - object-fit: cover; +.avatar-wrapper:hover .avatar-edit-overlay { + opacity: 1; } -.upload-overlay { +/* Upload Progress */ +.avatar-upload-progress { position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; + right: 0; + bottom: 0; display: flex; - justify-content: center; align-items: center; - background-color: rgba(0, 0, 0, 0.6); - color: white; - font-weight: bold; + justify-content: center; + background-color: rgba(0, 0, 0, 0.7); } -.profile-error-message { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - background-color: rgba(255, 0, 0, 0.8); - color: white; - padding: 5px; - text-align: center; - font-size: 0.8rem; +.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; } -.success-message { +/* Rank Badge */ +.rank-badge { position: absolute; - bottom: 0; - left: 0; - width: 100%; - background-color: rgba(40, 167, 69, 0.8); - color: white; - padding: 5px; + 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; +} + +/* Upload Messages */ +.upload-message { + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.85rem; text-align: center; - font-size: 0.8rem; + max-width: 200px; } -.selectable-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; +.upload-message.error { + background-color: rgba(220, 53, 69, 0.2); + color: #ff6b6b; + border: 1px solid rgba(220, 53, 69, 0.3); } -.username-info { - display: flex; - width: 500px; - justify-content: space-between; +.upload-message.success { + background-color: rgba(40, 167, 69, 0.2); + color: #51cf66; + border: 1px solid rgba(40, 167, 69, 0.3); } -.username-info h2 { - font-size: 2rem; +/* ========== USER IDENTITY ========== */ +.user-identity { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.75rem; } -.title-select { - position: relative; - width: 95%; - margin-bottom: 0.5rem; +.username { + font-size: 2.5rem; + font-weight: 700; + color: #fff; + margin: 0; + letter-spacing: -0.5px; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } -.selected-title:hover{ - border-color: var(--mode-text-color); +/* ========== TITLE SELECTOR ========== */ +.title-selector { + position: relative; + max-width: 300px; } -.selected-title { - background-color: var(--background-color); +.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); - border: 1px solid #F58025; - border-radius: 4px; - padding: 0.5rem 0.8rem; - font-size: 1.5rem; + font-size: 1rem; cursor: pointer; + transition: all 0.2s ease; +} + +.title-button:hover, +.title-button.active { + background-color: rgba(245, 128, 37, 0.15); + border-color: #F58025; +} + +.title-text { + font-weight: 500; +} + +.dropdown-chevron { display: flex; - justify-content: space-between; - align-items: center; - transition: border-color 0.2s ease; + transition: transform 0.2s ease; +} + +.dropdown-chevron.open { + transform: rotate(180deg); } -.dropdown-arrow { +.dropdown-chevron .material-icons { + font-size: 20px; color: #F58025; - font-size: 0.8rem; - transition: transform 0.2s; } +/* Title Dropdown */ .title-dropdown { position: absolute; - top: 100%; + top: calc(100% + 8px); 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; + min-width: 280px; + 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: 1000; max-height: 300px; overflow-y: auto; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - animation: slideDown 0.2s ease-out; + animation: dropdownSlide 0.2s ease-out; } -@keyframes slideDown { +@keyframes dropdownSlide { from { opacity: 0; transform: translateY(-10px); @@ -300,720 +480,1134 @@ } } -.dropdown-option { - padding: 0.8rem 1rem; - font-size: 1.1rem; - cursor: pointer; - transition: all 0.2s ease; - text-align: left; +.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); +} + +.title-option { display: flex; - flex-direction: column; - gap: 4px; - border-bottom: 1px solid rgba(245, 128, 37, 0.1); + 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); } -.dropdown-option:last-child { +.title-option:last-child { border-bottom: none; } -.dropdown-option:hover { +.title-option:hover { background-color: rgba(245, 128, 37, 0.1); } -.dropdown-option.locked { - opacity: 0.6; - cursor: not-allowed; - background-color: rgba(0, 0, 0, 0.05); +.title-option.selected { + background-color: rgba(245, 128, 37, 0.15); } -.dropdown-option.locked:hover { - background-color: rgba(0, 0, 0, 0.1); +.title-option.locked { + opacity: 0.5; + cursor: not-allowed; } -.title-locked-indicator { - display: flex; - align-items: center; - gap: 4px; - font-size: 0.9rem; - color: #888; - margin-top: 4px; +.title-option.locked:hover { + background-color: rgba(255, 255, 255, 0.03); } -.title-locked-indicator .material-icons { - font-size: 1rem; +.title-option.deselect { + color: #F58025; + font-weight: 500; } -.dropdown-option.loading, -.dropdown-option.disabled { +.title-option.loading, +.title-option.disabled { + justify-content: center; + color: #888; font-style: italic; cursor: default; - padding: 1rem; - text-align: center; } -.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; +.option-content { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.deselect-option { - color: #F58025; +.option-name { font-weight: 500; - border-bottom: 1px solid rgba(245, 128, 37, 0.3); + color: var(--mode-text-color); } -.deselect-option:hover { - background-color: rgba(245, 128, 37, 0.15); +.option-desc { + font-size: 0.8rem; + color: #888; } -/* When dropdown is open, change arrow direction */ -.title-select.open .dropdown-arrow { - transform: rotate(180deg); +.lock-icon, +.check-icon { + font-size: 18px; } -.user-badges h3{ - text-align: left; - font-size: 1.8rem; - margin-bottom: 0.5rem; +.lock-icon { + color: #666; } - -.profile-user-edit { - width: 1.875rem; - height: 1.875rem; - mix-blend-mode: screen; +.check-icon { + color: #F58025; } -.biography { - padding-top: 2.2rem; - padding-right: 2rem; - display: flex; - flex-direction: column; +/* Read-only Title Display */ +.title-display { + padding: 0.5rem 0; } -.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; +.title-loading { + color: #888; + font-style: italic; } -.biography-input::placeholder { - color: gray; +.equipped-title { + color: var(--mode-text-color); + font-size: 1.1rem; + font-weight: 500; + cursor: help; } -.biography-input:focus { - outline: none; - border-color: #F58025; - box-shadow: 0 0 0 0.2rem rgba(245, 128, 37, 0.25); +.no-title { + color: #666; + font-style: italic; } -.bio-controls { +/* ========== HERO QUICK STATS ========== */ +.hero-quick-stats { display: flex; align-items: center; - flex-direction: row-reverse; + gap: 0; margin-top: 0.5rem; + padding: 0.6rem 1.25rem; + background-color: rgba(0, 0, 0, 0.25); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); } -.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; -} - -.save-bio-btn:hover { - background-color: #e06800; -} - -.save-bio-btn:disabled { - background-color: #ccc; - cursor: not-allowed; -} - -.bio-success, .bio-error { - margin-left: 20px; - padding: 5px 10px; - border-radius: 4px; - font-size: 1.3rem; - margin-right: 20px; -} - -.bio-success { - background-color: rgba(40, 167, 69, 0.2); - color: #28a745; -} - -.bio-error { - background-color: rgba(220, 53, 69, 0.2); - color: #dc3545; -} - -.match-history { - flex: 1 1 45%; - min-width: 0; - font-size: 2rem; +.quick-stat { display: flex; flex-direction: column; + align-items: center; + padding: 0 1.25rem; } -.match-history h2 { - font-size: 2rem; - color: var(--mode-text-color); - margin-bottom: 0.5rem; +.quick-stat-divider { + width: 1px; + height: 32px; + background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.2) 50%, transparent 100%); } -.match-history-list { - display: flex; - flex-direction: column; - gap: 1rem; - padding-top: 5px; - padding-right: 10px; - padding-left: 10px; - height: 400px; - overflow-y: auto; +.quick-stat .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #fff; + line-height: 1; } -/* Custom Scrollbar for Match History */ -/* i like this alot should make it global later */ -.match-history-list::-webkit-scrollbar { - width: 8px; -} -.match-history-list::-webkit-scrollbar-track { - background: var(--secondary-color); - border-radius: 4px; -} -.match-history-list::-webkit-scrollbar-thumb { - background-color: #F58025; - border-radius: 4px; - border: 2px solid var(--secondary-color); +.quick-stat .stat-label { + font-size: 0.65rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 0.25rem; } -.match-history-list::-webkit-scrollbar-thumb:hover { - background-color: #e06800; + +/* ========== MAIN CONTENT GRID ========== */ +.profile-main { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + padding: 1.25rem; } -.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; +.profile-column { display: flex; flex-direction: column; + gap: 1rem; } -.match-history-card:hover { - transform: translateY(-3px) scale(1.01); - border-color: #F58025; - box-shadow: 0 4px 10px rgba(245, 128, 37, 0.15); -} - -.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; +/* ========== 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; } -.match-details { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 1.5rem; - padding-top: 0.8rem; - min-height: 60px; +.profile-section:hover { + border-color: rgba(245, 128, 37, 0.2); } -.match-position { - grid-column: 1 / 2; +.section-header { 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; + 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); } -.match-info-details { - grid-column: 2 / 3; +.section-header h3 { display: flex; - justify-content: space-between; align-items: center; -} - -.match-type { - font-size: 1rem; + gap: 0.4rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; color: var(--mode-text-color); - display: flex; - flex-direction: column; - text-align: left; - gap: 0.2rem; } -.match-lobby-type { - font-weight: 600; - font-size: 1.1rem; +.section-header h3 .material-icons { + font-size: 18px; + color: #F58025; } -.match-category { - font-style: italic; - font-size: 0.9rem; - color: var(--text-color-secondary); +.section-content { + padding: 1rem; } -.match-position .position-number { - font-size: 2.4rem; - font-weight: bold; - line-height: 1.1; - color: var(--text-color-secondary); +/* ========== BIO SECTION ========== */ +.bio-editor { + display: flex; + flex-direction: column; + gap: 0.75rem; } -.match-position-label { - font-size: 0.8rem; - color: var(--text-color-secondary); - margin-top: -2px; - text-transform: uppercase; +.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-family: inherit; + font-size: 0.85rem; + line-height: 1.4; + resize: vertical; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } -.match-position.first-place .position-number { - color: #FFD700; - text-shadow: 0 0 6px rgba(255, 215, 0, 0.6); +.bio-textarea::placeholder { + color: #666; } -.match-position.second-place .position-number { - color: #C0C0C0; - text-shadow: 0 0 5px rgba(192, 192, 192, 0.4); +.bio-textarea:focus { + outline: none; + border-color: #F58025; + box-shadow: 0 0 0 3px rgba(245, 128, 37, 0.15); } -.match-position.third-place .position-number { - color: #CD7F32; - text-shadow: 0 0 5px rgba(205, 127, 50, 0.4); +.bio-footer { + display: flex; + align-items: center; + justify-content: space-between; } -.match-stats { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.3rem; +.char-count { + font-size: 0.8rem; + color: #888; } -.match-stats span { - font-size: 1.1rem; - color: #F58025; +.save-bio-btn { display: flex; align-items: center; - gap: 0.4rem; + 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; + transition: all 0.2s ease; } -/* Add icons to match stats */ -.match-stats span i { - font-size: 1rem; - line-height: 1; +.save-bio-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(245, 128, 37, 0.4); } -.profile-stats h2 { - color: #F58025; - margin-bottom: 1.5rem; - font-size: 2rem; +.save-bio-btn:disabled { + opacity: 0.6; + cursor: not-allowed; } -.primary-stats { - margin-bottom: 1.5rem; +.save-bio-btn .material-icons { + font-size: 18px; } -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1.5rem; +.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; } -.stat-card { - background: linear-gradient(135deg, var(--hover-color) 0%, var(--secondary-color) 100%); - color: var(--mode-text-color); - padding: 1.4rem; +.bio-message { + padding: 0.6rem 1rem; border-radius: 8px; - border: 1px solid rgba(245, 128, 37, 0.2); + font-size: 0.85rem; 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); } -.profile-stat { - flex-direction: column!important; +.bio-message.success { + background-color: rgba(40, 167, 69, 0.15); + color: #51cf66; + border: 1px solid rgba(40, 167, 69, 0.3); } -.stat-card - -.stat-card:hover { - transform: translateY(-5px); - box-shadow: 0 5px 15px rgba(245, 128, 37, 0.2); +.bio-message.error { + background-color: rgba(220, 53, 69, 0.15); + color: #ff6b6b; + border: 1px solid rgba(220, 53, 69, 0.3); } -.stat-card h3 { +/* Read-only Bio */ +.bio-display { color: var(--mode-text-color); - margin-bottom: 0.8rem; - font-size: 1.5rem; + line-height: 1.6; +} + +.bio-display p { + margin: 0; + white-space: pre-wrap; +} + +/* ========== BADGES SECTION ========== */ +.edit-badges-btn { + width: 32px; + height: 32px; display: flex; align-items: center; justify-content: center; - gap: 0.5rem; + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #888; + cursor: pointer; + transition: all 0.2s ease; } -.stat-card h3 i { +.edit-badges-btn:hover { + background-color: rgba(245, 128, 37, 0.1); + border-color: #F58025; color: #F58025; - font-size: 1.4rem; - line-height: 1; } -.stat-card p { - color: var(--text-color-highlight); - font-size: 1.8rem; - font-weight: bold; - margin: 0; +.edit-badges-btn .material-icons { + font-size: 18px; } -/* Badge display and selection styling */ -.badge-display { - min-height: 50px; +.badges-showcase { display: flex; flex-wrap: wrap; - gap: 2rem; - margin-top: 10px; - justify-content: flex-start; + gap: 0.5rem; } -.badge-item { - width: 50px; - height: 50px; - border-radius: 50%; +.badge-card { display: flex; - flex-direction: column; - justify-content: center; align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; cursor: pointer; transition: all 0.2s ease; - background-color: var(--hover-color); - position: relative; } -.badge-item.selected { - background-color: rgba(245, 128, 37, 0.2); - border: 2px solid #F58025; +.badge-card:hover { + background-color: rgba(245, 128, 37, 0.1); + border-color: rgba(245, 128, 37, 0.3); + transform: translateY(-2px); } -.badge-item.placeholder { - border: 2px dashed #777; +.badge-card.empty { + border-style: dashed; + border-color: rgba(255, 255, 255, 0.15); } -.badge-item:hover { - transform: scale(1.05); +.badge-card.empty:hover { + border-color: #F58025; } -.badge-item:hover .badge-name { - opacity: 1; +.badge-card.empty .badge-icon { + color: #666; } -.badge-emoji { - font-size: 1.8rem; +.badge-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; } -.badge-image { - width: 4.5rem; - height: 4.5rem; +.badge-icon img { + width: 24px; + height: 24px; object-fit: contain; } -.badge-plus { - font-size: 2rem; - color: #777; -} - -.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; -} - -/* 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; +.badge-emoji { + font-size: 1.2rem; } -.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; +.badge-info { display: flex; flex-direction: column; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); } -.badge-selector h4 { +.badge-info .badge-name { + font-size: 0.75rem; + font-weight: 500; color: var(--mode-text-color); - margin-bottom: 1rem; - font-size: 1.5rem; - text-align: center; } -.badge-grid { +.no-badges { display: flex; flex-direction: column; - gap: 10px; - overflow-y: auto; - max-height: 400px; - padding: 10px; -} - -.badge-selection-item { - 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); -} - -.badge-selection-item:hover { - background-color: rgba(245, 128, 37, 0.1); -} - -.badge-selection-item.selected { - background-color: rgba(245, 128, 37, 0.2); - border: 1px solid #F58025; + gap: 0.5rem; + padding: 1.5rem; + color: #888; + text-align: center; + width: 100%; } -.badge-details { - display: flex; - flex-direction: column; - margin-left: 15px; +.no-badges .material-icons { + font-size: 32px; + color: #666; } -.badge-details .badge-modal-name { - position: static; - transform: none; - width: auto; - font-weight: bold; - color: var(--mode-text-color); - font-size: 1rem; +.no-badges p { margin: 0; - text-align: left; -} - -.badge-details .badge-modal-description { font-size: 0.9rem; - color: var(--text-color-secondary); - margin-top: 2px; } -.badge-selector-actions { +/* ========== STATS SECTION ========== */ +.stats-tabs { display: flex; - justify-content: flex-end; - margin-top: 1.5rem; - gap: 10px; + gap: 0.25rem; + background-color: rgba(0, 0, 0, 0.2); + padding: 0.25rem; + border-radius: 8px; } -.badge-cancel, .badge-save { - padding: 8px 16px; - border-radius: 4px; - font-weight: bold; +.stats-tabs .tab { + padding: 0.4rem 0.9rem; + background-color: transparent; + border: none; + border-radius: 6px; + color: #888; + font-size: 0.8rem; + font-weight: 500; cursor: pointer; - font-size: 1rem; - transition: all 0.2s ease; + transition: all 0.15s ease; } -.badge-cancel { - background-color: transparent; - color: var(--text-color-secondary); - border: 1px solid #555; +.stats-tabs .tab:hover { + color: var(--mode-text-color); } -.badge-save { +.stats-tabs .tab.active { background-color: #F58025; - color: var(--text-color-highlight); - border: none; + color: #fff; } -.badge-loading, .no-badges { - padding: 20px; - text-align: center; - color: var(--text-color-secondary); - font-style: italic; +/* Stats Grid - Overview */ +.stats-grid.overview { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.6rem; } -.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; +.stat-tile { display: flex; + flex-direction: row; align-items: center; + gap: 0.6rem; + padding: 0.7rem 0.9rem; + 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; } -.no-matches { - font-style: italic; - color: var(--text-color-secondary); - font-size: 1.1rem; - padding: 10px 0; +.stat-tile .stat-title { width: 100%; - text-align: center; - margin-left: 0; - min-height: 50px; + margin-top: 0.2rem; +} + +.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: 32px; + height: 32px; display: flex; align-items: center; justify-content: center; + background-color: rgba(245, 128, 37, 0.1); + border-radius: 8px; + 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: 18px; + 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.2rem; +} + +.stat-number { + font-size: 1.3rem; + 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.75rem; + font-weight: 500; + color: #888; +} + +.stat-title { + font-size: 0.65rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* Stats Grid - Detailed */ +.stats-grid.detailed { display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.stat-row { + display: flex; + justify-content: space-between; align-items: center; - transition: border-color 0.2s ease; + padding: 0.55rem 0.8rem; + 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); } -.displayed-title-name { +.row-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: #aaa; +} + +.row-label .material-icons { + font-size: 16px; + color: #F58025; +} + +.row-value { + font-size: 0.9rem; 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; +} + +/* ========== 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; } -/* Title tooltip styles for profile modal */ -.displayed-title-name.title-with-tooltip { +.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); +} + +.metric-value.wpm { + color: #F58025; +} + +.metric-value.accuracy { + color: #2ECC71; } -.read-only-bio-container { - resize: none; - border-radius: 15px; - padding: 15px; +.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; +} + +/* ========== BADGE SELECTOR MODAL ========== */ +.badge-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 2000; + padding: 1rem; +} + +.badge-modal { 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; + max-width: 500px; + max-height: 80vh; + background-color: var(--secondary-color); + border: 1px solid rgba(245, 128, 37, 0.3); + border-radius: 20px; + display: flex; + flex-direction: column; + overflow: hidden; + animation: modalSlideIn 0.3s ease-out; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + background-color: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.modal-header h3 { + margin: 0; + font-size: 1.2rem; + color: var(--mode-text-color); +} + +.modal-close { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #888; + cursor: pointer; + transition: all 0.2s ease; +} + +.modal-close:hover { + background-color: rgba(220, 53, 69, 0.2); + border-color: rgba(220, 53, 69, 0.3); + color: #ff6b6b; +} + +.modal-subtitle { + margin: 0; + padding: 1rem 1.5rem; + font-size: 0.9rem; + color: #888; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.badge-selection-grid { + flex: 1; overflow-y: auto; + padding: 1rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.selection-badge { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.9rem 1rem; + background-color: rgba(0, 0, 0, 0.15); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + cursor: pointer; + transition: all 0.15s ease; +} + +.selection-badge:hover { + background-color: rgba(245, 128, 37, 0.1); + border-color: rgba(245, 128, 37, 0.2); +} + +.selection-badge.selected { + background-color: rgba(245, 128, 37, 0.15); + border-color: #F58025; +} + +.selection-checkbox { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.2); + border: 2px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.selection-badge.selected .selection-checkbox { + background-color: #F58025; + border-color: #F58025; +} + +.selection-checkbox .material-icons { + font-size: 16px; + color: #fff; } -.read-only-bio-container .bio-text { +.selection-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.selection-icon img { + width: 36px; + height: 36px; + object-fit: contain; +} + +.selection-info { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; + min-width: 0; +} + +.selection-name { + font-weight: 600; + color: var(--mode-text-color); + font-size: 0.95rem; +} + +.selection-desc { + font-size: 0.8rem; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge-loading, +.no-badges-modal { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem; + color: #888; + text-align: center; +} + +.no-badges-modal .material-icons { + font-size: 40px; + color: #666; +} + +.no-badges-modal p { margin: 0; - text-align: left; - white-space: pre-wrap; + font-size: 1rem; + color: var(--mode-text-color); } -.read-only-bio-container .bio-text:empty::before { - content: 'This user hasn\'t written a bio yet.'; - color: #888888; - font-style: italic; +.no-badges-modal span { + font-size: 0.85rem; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background-color: rgba(0, 0, 0, 0.15); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.selection-count { + font-size: 0.85rem; + color: #888; +} + +.modal-actions { + display: flex; + gap: 0.75rem; +} + +.btn-cancel, +.btn-save { + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-cancel { + background-color: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + color: #888; +} + +.btn-cancel:hover { + background-color: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--mode-text-color); +} + +.btn-save { + background: linear-gradient(135deg, #F58025 0%, #d16a1c 100%); + border: none; + color: #fff; +} + +.btn-save:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(245, 128, 37, 0.4); +} + +/* ========== 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-selector { + max-width: 100%; + } + + .hero-quick-stats { + flex-wrap: wrap; + justify-content: center; + } + + .quick-stat-divider { + display: none; + } + + .quick-stat { + padding: 0.5rem 0.75rem; + } +} + +@media (max-width: 600px) { + .profile-container { + border-radius: 16px; + } + + .profile-hero { + padding: 1.5rem 1rem 1.25rem; + border-radius: 16px 16px 0 0; + } + + .avatar-container { + width: 100px; + height: 100px; + } + + .username { + font-size: 1.5rem; + } + + .quick-stat .stat-value { + font-size: 1.2rem; + } + + .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..1ce6cab9 100644 --- a/client/src/components/ProfileModal.jsx +++ b/client/src/components/ProfileModal.jsx @@ -2,7 +2,6 @@ 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'; function ProfileModal({ isOpen, onClose, netid }) { const { user, loading, setUser, fetchUserProfile } = useAuth(); @@ -19,7 +18,7 @@ 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([]); @@ -32,10 +31,21 @@ function ProfileModal({ isOpen, onClose, netid }) { 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 modalRef = useRef(); const typingInputRef = document.querySelector('.typing-input-container input'); + // Determine user's rank tier based on average WPM - used for visual styling + const getRankTier = (avgWpm) => { + if (avgWpm >= 150) return { tier: 'legendary', label: 'Legendary', color: '#FFD700' }; + if (avgWpm >= 125) return { tier: 'master', label: 'Master', color: '#9B59B6' }; + if (avgWpm >= 100) return { tier: 'expert', label: 'Expert', color: '#3498DB' }; + if (avgWpm >= 75) return { tier: 'advanced', label: 'Advanced', color: '#2ECC71' }; + if (avgWpm >= 50) return { tier: 'intermediate', label: 'Intermediate', color: '#F58025' }; + return { tier: 'beginner', label: 'Beginner', color: '#95A5A6' }; + }; + const getBadgeEmoji = (key) => { switch (key) { case 'first_race': return '🏁'; @@ -119,11 +129,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 +220,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 @@ -265,7 +275,7 @@ function ProfileModal({ isOpen, onClose, netid }) { 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)); @@ -278,32 +288,32 @@ function ProfileModal({ isOpen, onClose, netid }) { 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)); + 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]); + }, [isOpen, userBadges, maxBadges]); const saveBadgeSelections = () => { const badgeIds = displayedBadges.map(badge => badge.id.toString()); @@ -324,7 +334,7 @@ function ProfileModal({ isOpen, onClose, netid }) { }).catch(err => console.error('Error saving badge selections:', err)); setShowBadgeSelector(false); }; - + const handleTitleClick = () => { setShowTitleDropdown(!showTitleDropdown); }; @@ -334,15 +344,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 +360,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(); @@ -470,9 +480,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 +516,7 @@ function ProfileModal({ isOpen, onClose, netid }) { const file = e.target.files[0]; if (!file) { - return; + return; } // Validate file type @@ -544,16 +554,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 +588,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 +629,29 @@ function ProfileModal({ isOpen, onClose, netid }) { fetchAllTitles(); }, [isOpen]); + // Get user rank info for visual styling + const userRank = getRankTier(parseNumericValue(displayUser?.avg_wpm)); + + // 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 ( +
+
+
+
+ Loading profile... +
- ); +
+ ); } // If modal is closed, render nothing (after all hooks) @@ -638,21 +662,24 @@ function ProfileModal({ isOpen, onClose, netid }) { return (
- -
- -
-

Profile

+ {/* Close Button */} + + + {/* ==================== HERO SECTION ==================== */} +
+
+
+
-
-
-
-
-
-
+
+ {/* Avatar Section */} +
+
+
+
{isOwnProfile ? ( <> {/* Hover hint overlay for discoverability */} - + { - 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 {`${displayUser?.netid )}
+ {/* Rank badge positioned below avatar */} +
+ {userRank.label} +
+
-
-
-

{displayUser?.netid || 'Guest'}

-
+ {/* Upload Messages */} + {uploadError &&
{uploadError}
} + {uploadSuccess &&
{uploadSuccess}
} +
- {/* Title Section - Conditional */} - {isOwnProfile ? ( -
+ {/* User Identity */} +
+

{displayUser?.netid || 'Guest'}

+ + {/* Title Section - Conditional */} + {isOwnProfile ? ( +
+ + + {showTitleDropdown && ( +
+
Choose Your Title
+ {/* Add Deselect Option */}
selectTitle(null)} > - {selectedTitle ? - userTitles.find(t => String(t.id) === String(selectedTitle))?.name || 'Select a title...' - : 'Select a title...'} - + No 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
- )} -
+ {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} + ) : ( - // 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 - )} -
+ // Display message if no title is equipped + No title equipped )} +
+ )} -
-

Badges

- -
- {/* Show selected badges for self, all badges for others */} - {(isOwnProfile ? displayedBadges : userBadges).map((badge, index) => ( -
setShowBadgeSelector(true) : undefined} - style={!isOwnProfile ? { cursor: 'default' } : {}} - > - {badge.icon_url ? ( - {badge.name} - ) : ( - {getBadgeEmoji(badge.key)} - )} - {badge.name} -
- ))} - - {/* Display message if viewing other profile with no badges */} - {!isOwnProfile && userBadges.length === 0 && !loadingBadges && ( -
No badges earned yet.
- )} - - {/* 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
+
+
+
-
+ {/* ==================== 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.'}

)}
-
+
+ + {/* Badges Section */} +
+
+

military_tech Badges

+ {isOwnProfile && ( + + )} +
+
+
+ {/* Show selected badges for self, all badges for others */} + {(isOwnProfile ? displayedBadges : userBadges).length > 0 ? ( + (isOwnProfile ? displayedBadges : userBadges).map((badge) => ( +
setShowBadgeSelector(true) : undefined} + style={!isOwnProfile ? { cursor: 'default' } : {}} + > +
+ {badge.icon_url ? ( + {badge.name} + ) : ( + {getBadgeEmoji(badge.key)} + )} +
+
+ {badge.name} +
+
+ )) + ) : ( +
+ emoji_events +

{isOwnProfile ? 'Complete races to earn badges!' : 'No badges earned yet.'}

+
+ )} -
-

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'; + {/* Only show placeholder add badges if own profile */} + {isOwnProfile && Array.from({ length: Math.max(0, 3 - displayedBadges.length) }, (_, i) => ( +
setShowBadgeSelector(true)} + > +
+ add +
+
+ Add Badge +
+
+ ))} +
+
+
+ + {/* Detailed Stats Section */} +
+
+

insights Statistics

+
+ + +
+
+
+ {activeStatsTab === 'overview' ? ( +
+
+
+ speed +
+
+ {parseNumericValue(displayUser?.avg_wpm).toFixed(1)} + WPM +
+ Average Speed +
- 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 ? ( +
+
+ Loading stats... +
+ ) : detailedStats ? ( + <> +
+ + play_arrow + Tests Started + + {formatNumber(detailedStats.sessions_started)} +
+
+ + done_all + Tests Completed + + {formatNumber(detailedStats.sessions_completed)}
-
- {/* Position Column (now first) */} -
-
{match.position || '-'}
-
Position
+
+ + keyboard + Words Typed + + {formatNumber(detailedStats.words_typed)} +
+
+ + pie_chart + Completion Rate + + {completionRate}% +
+ + ) : ( +
No detailed stats available
+ )} +
+ )} +
+
+
+ + {/* Right Column - Match History */} +
+
+
+

history Recent Matches

+
+
+ {loadingMatchHistory ? ( +
+
+ Loading history... +
+ ) : 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)}

+ {/* ==================== BADGE SELECTOR MODAL ==================== */} + {showBadgeSelector && ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > +
+
+

Select Badges to Display

+
-
-

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()} +

Choose up to {maxBadges} badges to showcase on your profile

+ +
+ {loadingBadges ? ( +
+
+ Loading badges... +
+ ) : userBadges.length > 0 ? ( + userBadges.map(badge => { + const isSelected = displayedBadges.some(b => b.id === badge.id); + return ( +
toggleBadgeSelection(badge)} > -
-

Select Badges to Display

-
- {loadingBadges ? ( -
Loading badges...
- ) : userBadges.length > 0 ? ( - userBadges.map(badge => ( -
b.id === badge.id) ? 'selected' : ''}`} - onClick={() => toggleBadgeSelection(badge)} - > - {badge.icon_url ? ( - {badge.name} - ) : ( - {getBadgeEmoji(badge.key)} - )} -
- {badge.name} - {badge.description} -
-
- )) - ) : ( -
No badges earned yet. Complete races to earn badges!
- )} -
-
- - -
+
+ {isSelected && check} +
+
+ {badge.icon_url ? ( + {badge.name} + ) : ( + {getBadgeEmoji(badge.key)} + )} +
+
+ {badge.name} + {badge.description}
- )} + ); + }) + ) : ( +
+ emoji_events +

No badges earned yet.

+ Complete races to earn badges! +
+ )} +
+ +
+ {displayedBadges.length}/{maxBadges} selected +
+ + +
+
+
+
+ )} +
); } From 5a8286968af3fd6020024e3d1b76d8a604b9a901 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 19:54:12 -0500 Subject: [PATCH 2/5] finalizing profile modal redesign --- client/src/components/Leaderboard.jsx | 66 +- client/src/components/ProfileModal.css | 730 +++++++++------------- client/src/components/ProfileModal.jsx | 597 +++++++----------- client/src/components/SegmentedToggle.css | 102 +++ client/src/components/SegmentedToggle.jsx | 70 +++ 5 files changed, 695 insertions(+), 870 deletions(-) create mode 100644 client/src/components/SegmentedToggle.css create mode 100644 client/src/components/SegmentedToggle.jsx 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 ( -
- - ); -} - -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, -}; - // Helper function to format relative time const formatRelativeTime = (timestamp) => { const nowUtc = Date.now(); // Current time in UTC milliseconds since epoch diff --git a/client/src/components/ProfileModal.css b/client/src/components/ProfileModal.css index 1b874825..e8feeb95 100644 --- a/client/src/components/ProfileModal.css +++ b/client/src/components/ProfileModal.css @@ -131,34 +131,38 @@ /* ========== HERO SECTION ========== */ .profile-hero { position: relative; - padding: 2rem 2rem 1.5rem; + padding: 1.5rem 2rem 1.25rem; overflow: hidden; border-radius: 24px 24px 0 0; } -/* Rank-based hero backgrounds */ -.profile-hero.legendary { - background: linear-gradient(135deg, #1a1400 0%, #2d2000 50%, #1a1400 100%); -} - -.profile-hero.master { - background: linear-gradient(135deg, #1a0f20 0%, #251530 50%, #1a0f20 100%); +/* Rank-based hero backgrounds (Princeton-themed tiers) */ +.profile-hero.freshman { + background: linear-gradient(135deg, #151515 0%, #1e1e1e 50%, #151515 100%); } -.profile-hero.expert { +.profile-hero.sophomore { background: linear-gradient(135deg, #0f1a24 0%, #152535 50%, #0f1a24 100%); } -.profile-hero.advanced { +.profile-hero.junior { background: linear-gradient(135deg, #0f1a14 0%, #15251a 50%, #0f1a14 100%); } -.profile-hero.intermediate { +.profile-hero.senior { background: linear-gradient(135deg, #1a1208 0%, #2d1f0d 50%, #1a1208 100%); } -.profile-hero.beginner { - background: linear-gradient(135deg, #151515 0%, #1e1e1e 50%, #151515 100%); +.profile-hero.gradstudent { + background: linear-gradient(135deg, #1a0f20 0%, #251530 50%, #1a0f20 100%); +} + +.profile-hero.supersenior { + background: linear-gradient(135deg, #1a0f0f 0%, #2d1515 50%, #1a0f0f 100%); +} + +.profile-hero.einstein { + background: linear-gradient(135deg, #1a1400 0%, #2d2000 50%, #1a1400 100%); } /* Hero Background Effects */ @@ -182,30 +186,70 @@ top: -100px; left: 50%; transform: translateX(-50%); + animation: glowPulse 3s ease-in-out infinite; } -.profile-hero.legendary .hero-glow { - background: radial-gradient(circle, #FFD700 0%, transparent 70%); +@keyframes glowPulse { + 0%, 100% { + opacity: 0.4; + transform: translateX(-50%) scale(1); + } + 50% { + opacity: 0.6; + transform: translateX(-50%) scale(1.1); + } } -.profile-hero.master .hero-glow { - background: radial-gradient(circle, #9B59B6 0%, transparent 70%); +.profile-hero.freshman .hero-glow { + background: radial-gradient(circle, #95A5A6 0%, transparent 70%); } -.profile-hero.expert .hero-glow { +.profile-hero.sophomore .hero-glow { background: radial-gradient(circle, #3498DB 0%, transparent 70%); } -.profile-hero.advanced .hero-glow { +.profile-hero.junior .hero-glow { background: radial-gradient(circle, #2ECC71 0%, transparent 70%); } -.profile-hero.intermediate .hero-glow { +.profile-hero.senior .hero-glow { background: radial-gradient(circle, #F58025 0%, transparent 70%); } -.profile-hero.beginner .hero-glow { - background: radial-gradient(circle, #95A5A6 0%, transparent 70%); +.profile-hero.gradstudent .hero-glow { + background: radial-gradient(circle, #9B59B6 0%, transparent 70%); +} + +.profile-hero.supersenior .hero-glow { + background: radial-gradient(circle, #E74C3C 0%, transparent 70%); +} + +.profile-hero.einstein .hero-glow { + background: radial-gradient(circle, #FFD700 0%, transparent 70%); + animation: glowPulseIntense 2s ease-in-out infinite; +} + +/* Intense glow animation for highest tier */ +@keyframes glowPulseIntense { + 0%, 100% { + opacity: 0.5; + transform: translateX(-50%) scale(1); + filter: blur(100px); + } + 50% { + opacity: 0.8; + transform: translateX(-50%) scale(1.15); + filter: blur(90px); + } +} + +/* Enhanced glow for higher tiers */ +.profile-hero.supersenior .hero-glow { + animation: glowPulse 2.5s ease-in-out infinite; +} + +.profile-hero.gradstudent .hero-glow { + animation: glowPulse 2.8s ease-in-out infinite; } .hero-particles { @@ -222,11 +266,67 @@ 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; @@ -263,12 +363,13 @@ opacity: 0.6; } -.avatar-container.legendary .avatar-ring { --ring-color: #FFD700; opacity: 0.8; } -.avatar-container.master .avatar-ring { --ring-color: #9B59B6; } -.avatar-container.expert .avatar-ring { --ring-color: #3498DB; } -.avatar-container.advanced .avatar-ring { --ring-color: #2ECC71; } -.avatar-container.intermediate .avatar-ring { --ring-color: #F58025; } -.avatar-container.beginner .avatar-ring { --ring-color: #95A5A6; } +.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; } @keyframes ringRotate { to { transform: rotate(360deg); } @@ -394,11 +495,11 @@ flex: 1; display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.5rem; } .username { - font-size: 2.5rem; + font-size: 2rem; font-weight: 700; color: #fff; margin: 0; @@ -406,6 +507,15 @@ text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } +/* Title + Stats Row */ +.title-stats-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-right: -2rem; /* Extend to edge of hero padding */ +} + /* ========== TITLE SELECTOR ========== */ .title-selector { position: relative; @@ -452,18 +562,14 @@ color: #F58025; } -/* Title Dropdown */ +/* Title Dropdown - Uses position: fixed to escape stacking context */ .title-dropdown { - position: absolute; - top: calc(100% + 8px); - left: 0; - width: 100%; - min-width: 280px; + /* 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: 1000; + z-index: 9999; max-height: 300px; overflow-y: auto; animation: dropdownSlide 0.2s ease-out; @@ -590,42 +696,126 @@ display: flex; align-items: center; gap: 0; - margin-top: 0.5rem; padding: 0.6rem 1.25rem; - background-color: rgba(0, 0, 0, 0.25); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); + 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); } .quick-stat { display: flex; flex-direction: column; align-items: center; - padding: 0 1.25rem; + padding: 0 1rem; } .quick-stat-divider { width: 1px; height: 32px; - background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.2) 50%, transparent 100%); + background: linear-gradient(180deg, transparent 0%, rgba(245, 128, 37, 0.4) 50%, transparent 100%); } .quick-stat .stat-value { - font-size: 1.5rem; + font-size: 1.4rem; font-weight: 700; color: #fff; line-height: 1; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } .quick-stat .stat-label { - font-size: 0.65rem; + font-size: 0.6rem; font-weight: 600; - color: rgba(255, 255, 255, 0.5); + color: rgba(245, 128, 37, 0.8); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 0.25rem; } +/* ========== 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); +} + +.rank-progress-header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; + font-size: 0.8rem; + font-weight: 600; +} + +.current-rank { + text-shadow: 0 0 10px currentColor; +} + +.rank-arrow { + color: rgba(255, 255, 255, 0.4); + font-size: 0.75rem; +} + +.next-rank { + opacity: 0.7; +} + +.rank-progress-bar { + height: 6px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.rank-progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.5s ease; + box-shadow: 0 0 10px currentColor; + position: relative; + overflow: hidden; +} + +.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; +} + +@keyframes progressShimmer { + 0% { left: -50%; } + 100% { left: 150%; } +} + +.rank-progress-text { + margin-top: 0.35rem; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.6); +} + +.rank-progress-text strong { + color: rgba(255, 255, 255, 0.9); +} + /* ========== MAIN CONTENT GRID ========== */ .profile-main { display: grid; @@ -793,161 +983,32 @@ white-space: pre-wrap; } -/* ========== BADGES SECTION ========== */ -.edit-badges-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background-color: transparent; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: #888; - cursor: pointer; - transition: all 0.2s ease; -} - -.edit-badges-btn:hover { - background-color: rgba(245, 128, 37, 0.1); - border-color: #F58025; - color: #F58025; -} - -.edit-badges-btn .material-icons { - font-size: 18px; -} - -.badges-showcase { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.badge-card { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s ease; -} - -.badge-card:hover { - background-color: rgba(245, 128, 37, 0.1); - border-color: rgba(245, 128, 37, 0.3); - transform: translateY(-2px); -} - -.badge-card.empty { - border-style: dashed; - border-color: rgba(255, 255, 255, 0.15); -} - -.badge-card.empty:hover { - border-color: #F58025; -} - -.badge-card.empty .badge-icon { - color: #666; -} - -.badge-icon { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; -} - -.badge-icon img { - width: 24px; - height: 24px; - object-fit: contain; -} - -.badge-emoji { - font-size: 1.2rem; -} - -.badge-info { - display: flex; - flex-direction: column; -} - -.badge-info .badge-name { - font-size: 0.75rem; - font-weight: 500; - color: var(--mode-text-color); -} - -.no-badges { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 1.5rem; - color: #888; - text-align: center; - width: 100%; -} - -.no-badges .material-icons { - font-size: 32px; +.bio-display p.empty-bio { color: #666; -} - -.no-badges p { - margin: 0; - font-size: 0.9rem; + font-size: 0.85rem; + font-style: italic; } /* ========== STATS SECTION ========== */ -.stats-tabs { - display: flex; - gap: 0.25rem; - background-color: rgba(0, 0, 0, 0.2); - padding: 0.25rem; - border-radius: 8px; -} - -.stats-tabs .tab { - padding: 0.4rem 0.9rem; - background-color: transparent; - border: none; - border-radius: 6px; - color: #888; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; -} - -.stats-tabs .tab:hover { - color: var(--mode-text-color); -} +/* Stats toggle uses SegmentedToggle component */ -.stats-tabs .tab.active { - background-color: #F58025; - color: #fff; +.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.6rem; + gap: 0.5rem; } .stat-tile { display: flex; flex-direction: row; align-items: center; - gap: 0.6rem; - padding: 0.7rem 0.9rem; + 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; @@ -957,7 +1018,7 @@ .stat-tile .stat-title { width: 100%; - margin-top: 0.2rem; + margin-top: 0.15rem; } .stat-tile:hover { @@ -978,13 +1039,13 @@ } .stat-icon { - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; background-color: rgba(245, 128, 37, 0.1); - border-radius: 8px; + border-radius: 6px; flex-shrink: 0; } @@ -993,7 +1054,7 @@ .stat-tile.success .stat-icon { background-color: rgba(46, 204, 113, 0.1); } .stat-icon .material-icons { - font-size: 18px; + font-size: 16px; color: #F58025; } @@ -1003,24 +1064,24 @@ .stat-data { display: flex; align-items: baseline; - gap: 0.2rem; + gap: 0.15rem; } .stat-number { - font-size: 1.3rem; + font-size: 1.15rem; font-weight: 700; color: var(--mode-text-color); } .stat-unit { - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 500; - color: #888; + color: #777; } .stat-title { - font-size: 0.65rem; - color: #888; + font-size: 0.6rem; + color: #777; text-transform: uppercase; letter-spacing: 0.3px; } @@ -1029,14 +1090,14 @@ .stats-grid.detailed { display: flex; flex-direction: column; - gap: 0.4rem; + gap: 0.35rem; } .stat-row { display: flex; justify-content: space-between; align-items: center; - padding: 0.55rem 0.8rem; + padding: 0.5rem 0.75rem; background-color: rgba(0, 0, 0, 0.15); border-radius: 6px; transition: background-color 0.15s ease; @@ -1054,18 +1115,18 @@ .row-label { display: flex; align-items: center; - gap: 0.4rem; - font-size: 0.8rem; - color: #aaa; + gap: 0.35rem; + font-size: 0.75rem; + color: #999; } .row-label .material-icons { - font-size: 16px; + font-size: 14px; color: #F58025; } .row-value { - font-size: 0.9rem; + font-size: 0.85rem; font-weight: 600; color: var(--mode-text-color); } @@ -1298,247 +1359,6 @@ margin: 0; } -/* ========== BADGE SELECTOR MODAL ========== */ -.badge-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(8px); - display: flex; - justify-content: center; - align-items: center; - z-index: 2000; - padding: 1rem; -} - -.badge-modal { - width: 100%; - max-width: 500px; - max-height: 80vh; - background-color: var(--secondary-color); - border: 1px solid rgba(245, 128, 37, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - overflow: hidden; - animation: modalSlideIn 0.3s ease-out; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.25rem 1.5rem; - background-color: rgba(0, 0, 0, 0.2); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.modal-header h3 { - margin: 0; - font-size: 1.2rem; - color: var(--mode-text-color); -} - -.modal-close { - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - background-color: transparent; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: #888; - cursor: pointer; - transition: all 0.2s ease; -} - -.modal-close:hover { - background-color: rgba(220, 53, 69, 0.2); - border-color: rgba(220, 53, 69, 0.3); - color: #ff6b6b; -} - -.modal-subtitle { - margin: 0; - padding: 1rem 1.5rem; - font-size: 0.9rem; - color: #888; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.badge-selection-grid { - flex: 1; - overflow-y: auto; - padding: 1rem 1.5rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.selection-badge { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.9rem 1rem; - background-color: rgba(0, 0, 0, 0.15); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 12px; - cursor: pointer; - transition: all 0.15s ease; -} - -.selection-badge:hover { - background-color: rgba(245, 128, 37, 0.1); - border-color: rgba(245, 128, 37, 0.2); -} - -.selection-badge.selected { - background-color: rgba(245, 128, 37, 0.15); - border-color: #F58025; -} - -.selection-checkbox { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(0, 0, 0, 0.2); - border: 2px solid rgba(255, 255, 255, 0.15); - border-radius: 6px; - flex-shrink: 0; - transition: all 0.15s ease; -} - -.selection-badge.selected .selection-checkbox { - background-color: #F58025; - border-color: #F58025; -} - -.selection-checkbox .material-icons { - font-size: 16px; - color: #fff; -} - -.selection-icon { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.selection-icon img { - width: 36px; - height: 36px; - object-fit: contain; -} - -.selection-info { - display: flex; - flex-direction: column; - gap: 0.15rem; - flex: 1; - min-width: 0; -} - -.selection-name { - font-weight: 600; - color: var(--mode-text-color); - font-size: 0.95rem; -} - -.selection-desc { - font-size: 0.8rem; - color: #888; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.badge-loading, -.no-badges-modal { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - padding: 2rem; - color: #888; - text-align: center; -} - -.no-badges-modal .material-icons { - font-size: 40px; - color: #666; -} - -.no-badges-modal p { - margin: 0; - font-size: 1rem; - color: var(--mode-text-color); -} - -.no-badges-modal span { - font-size: 0.85rem; -} - -.modal-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.5rem; - background-color: rgba(0, 0, 0, 0.15); - border-top: 1px solid rgba(255, 255, 255, 0.08); -} - -.selection-count { - font-size: 0.85rem; - color: #888; -} - -.modal-actions { - display: flex; - gap: 0.75rem; -} - -.btn-cancel, -.btn-save { - padding: 0.6rem 1.25rem; - border-radius: 8px; - font-weight: 600; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-cancel { - background-color: transparent; - border: 1px solid rgba(255, 255, 255, 0.15); - color: #888; -} - -.btn-cancel:hover { - background-color: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.2); - color: var(--mode-text-color); -} - -.btn-save { - background: linear-gradient(135deg, #F58025 0%, #d16a1c 100%); - border: none; - color: #fff; -} - -.btn-save:hover { - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(245, 128, 37, 0.4); -} - /* ========== RESPONSIVE DESIGN ========== */ @media (max-width: 900px) { .profile-main { @@ -1554,6 +1374,12 @@ align-items: center; } + .title-stats-row { + flex-direction: column; + margin-right: 0; + gap: 0.5rem; + } + .title-selector { max-width: 100%; } @@ -1561,6 +1387,8 @@ .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 { @@ -1568,7 +1396,7 @@ } .quick-stat { - padding: 0.5rem 0.75rem; + padding: 0.4rem 0.75rem; } } @@ -1578,21 +1406,25 @@ } .profile-hero { - padding: 1.5rem 1rem 1.25rem; + padding: 1.25rem 1rem 1rem; border-radius: 16px 16px 0 0; } .avatar-container { - width: 100px; - height: 100px; + width: 90px; + height: 90px; } .username { - font-size: 1.5rem; + font-size: 1.4rem; } .quick-stat .stat-value { - font-size: 1.2rem; + font-size: 1.1rem; + } + + .quick-stat .stat-label { + font-size: 0.55rem; } .profile-main { diff --git a/client/src/components/ProfileModal.jsx b/client/src/components/ProfileModal.jsx index 1ce6cab9..b0ae6f60 100644 --- a/client/src/components/ProfileModal.jsx +++ b/client/src/components/ProfileModal.jsx @@ -2,6 +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 SegmentedToggle from './SegmentedToggle'; function ProfileModal({ isOpen, onClose, netid }) { const { user, loading, setUser, fetchUserProfile } = useAuth(); @@ -21,41 +22,73 @@ function ProfileModal({ isOpen, onClose, netid }) { 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'); - // Determine user's rank tier based on average WPM - used for visual styling - const getRankTier = (avgWpm) => { - if (avgWpm >= 150) return { tier: 'legendary', label: 'Legendary', color: '#FFD700' }; - if (avgWpm >= 125) return { tier: 'master', label: 'Master', color: '#9B59B6' }; - if (avgWpm >= 100) return { tier: 'expert', label: 'Expert', color: '#3498DB' }; - if (avgWpm >= 75) return { tier: 'advanced', label: 'Advanced', color: '#2ECC71' }; - if (avgWpm >= 50) return { tier: 'intermediate', label: 'Intermediate', color: '#F58025' }; - return { tier: 'beginner', label: 'Beginner', color: '#95A5A6' }; + // 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 }; - 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 '🏆'; + // 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) @@ -243,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); }; @@ -414,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; @@ -629,8 +609,9 @@ function ProfileModal({ isOpen, onClose, netid }) { fetchAllTitles(); }, [isOpen]); - // Get user rank info for visual styling - const userRank = getRankTier(parseNumericValue(displayUser?.avg_wpm)); + // 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 @@ -742,105 +723,142 @@ function ProfileModal({ isOpen, onClose, netid }) {

{displayUser?.netid || 'Guest'}

- {/* Title Section - Conditional */} - {isOwnProfile ? ( -
- - - {showTitleDropdown && ( -
-
Choose Your Title
- {/* Add Deselect Option */} -
selectTitle(null)} - > - No Title -
- {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} + {/* Title + Quick Stats Row */} +
+ {/* Title Section - Conditional */} + {isOwnProfile ? ( +
+ + + {showTitleDropdown && ( +
+
Choose Your Title
+ {/* Add Deselect Option */} +
selectTitle(null)} + > + No Title +
+ {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 + )} +
+ )} + + {/* 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 +
- )} +
- {/* Quick Stats in Hero */} -
-
- {parseNumericValue(displayUser?.avg_wpm).toFixed(0)} - AVG WPM + {/* Rank Progress Bar */} +
+
+ + {rankProgress.currentTier.label} + + {rankProgress.nextTier && ( + <> + + + {rankProgress.nextTier.label} + + + )}
-
-
- {parseNumericValue(displayUser?.fastest_wpm).toFixed(0)} - BEST WPM +
+
-
-
- {parseNumericValue(displayUser?.avg_accuracy).toFixed(0)}% - ACCURACY +
+ {rankProgress.nextTier ? ( + {rankProgress.racesNeeded} races to {rankProgress.nextTier.label} + ) : ( + Max rank achieved! + )}
@@ -895,89 +913,28 @@ function ProfileModal({ isOpen, onClose, netid }) { ) : ( // Read-only bio for others
-

{displayUser?.bio || 'This user hasn\'t written a bio yet.'}

+

+ {displayUser?.bio || 'This user hasn\'t written a bio yet.'} +

)}
- {/* Badges Section */} -
-
-

military_tech Badges

- {isOwnProfile && ( - - )} -
-
-
- {/* Show selected badges for self, all badges for others */} - {(isOwnProfile ? displayedBadges : userBadges).length > 0 ? ( - (isOwnProfile ? displayedBadges : userBadges).map((badge) => ( -
setShowBadgeSelector(true) : undefined} - style={!isOwnProfile ? { cursor: 'default' } : {}} - > -
- {badge.icon_url ? ( - {badge.name} - ) : ( - {getBadgeEmoji(badge.key)} - )} -
-
- {badge.name} -
-
- )) - ) : ( -
- emoji_events -

{isOwnProfile ? 'Complete races to earn badges!' : 'No badges earned yet.'}

-
- )} - - {/* Only show placeholder add badges if own profile */} - {isOwnProfile && Array.from({ length: Math.max(0, 3 - displayedBadges.length) }, (_, i) => ( -
setShowBadgeSelector(true)} - > -
- add -
-
- Add Badge -
-
- ))} -
-
-
- {/* Detailed Stats Section */}

insights Statistics

-
- - -
+
{activeStatsTab === 'overview' ? ( @@ -1151,78 +1108,6 @@ function ProfileModal({ isOpen, onClose, netid }) {
- {/* ==================== BADGE SELECTOR MODAL ==================== */} - {showBadgeSelector && ( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > -
-
-

Select Badges to Display

- -
- -

Choose up to {maxBadges} badges to showcase on your profile

- -
- {loadingBadges ? ( -
-
- Loading badges... -
- ) : userBadges.length > 0 ? ( - userBadges.map(badge => { - const isSelected = displayedBadges.some(b => b.id === badge.id); - return ( -
toggleBadgeSelection(badge)} - > -
- {isSelected && check} -
-
- {badge.icon_url ? ( - {badge.name} - ) : ( - {getBadgeEmoji(badge.key)} - )} -
-
- {badge.name} - {badge.description} -
-
- ); - }) - ) : ( -
- emoji_events -

No badges earned yet.

- Complete races to earn badges! -
- )} -
- -
- {displayedBadges.length}/{maxBadges} selected -
- - -
-
-
-
- )}
); 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 ( +
+ + ); +} + +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; From b1e3c83e696350b3150ec8380acecb1472f100f5 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 21:39:50 -0500 Subject: [PATCH 3/5] full revamp of landing pages and lobby --- client/src/components/Modes.css | 177 +++++++++++++++++++++--- client/src/components/Modes.jsx | 2 +- client/src/components/Navbar.css | 80 ++++++++--- client/src/components/Results.css | 161 +++++++++++++++++---- client/src/components/StatsShowcase.css | 87 +++++++++--- client/src/components/StatsShowcase.jsx | 16 ++- client/src/components/Typing.jsx | 19 +-- client/src/index.css | 86 +++++++++++- client/src/pages/Home.css | 61 ++++++-- client/src/pages/Landing.css | 148 ++++++++++++++++---- server/controllers/socket-handlers.js | 6 +- 11 files changed, 691 insertions(+), 152 deletions(-) diff --git a/client/src/components/Modes.css b/client/src/components/Modes.css index 25b8a5f9..e4b426c3 100644 --- a/client/src/components/Modes.css +++ b/client/src/components/Modes.css @@ -1,64 +1,153 @@ +/* ============================================ + Mode Selection Cards - Enhanced UI + ============================================ */ + .modes-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 2rem; + gap: 1.5rem; width: 100%; } +/* Base Mode Card */ .mode-box { - background-color: var(--secondary-color); - border-radius: 8px; - padding: 2rem; + --mode-color: #F58025; + --mode-glow: rgba(245, 128, 37, 0.3); + + background: linear-gradient(145deg, rgba(30, 30, 30, 0.95) 0%, rgba(26, 26, 26, 0.98) 100%); + border-radius: 16px; + padding: 1.75rem; display: flex; flex-direction: column; align-items: center; text-align: center; - transition: transform 0.2s, box-shadow 0.2s; + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.35s ease, + border-color 0.35s ease; cursor: pointer; position: relative; overflow: hidden; - min-height: 280px; + min-height: 220px; justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); +} + +/* Gradient overlay that appears on hover */ +.mode-box::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient(circle at 50% 0%, var(--mode-glow) 0%, transparent 60%); + opacity: 0; + transition: opacity 0.35s ease; + pointer-events: none; +} + +/* Animated border glow */ +.mode-box::after { + content: ''; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + background: linear-gradient(135deg, var(--mode-color), transparent 50%, var(--mode-color)); + border-radius: 22px; + z-index: -1; + opacity: 0; + transition: opacity 0.35s ease; } .mode-box:hover { - transform: translateY(-3px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); + transform: translateY(-8px) scale(1.02); + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + 0 20px 40px rgba(0, 0, 0, 0.4), + 0 0 40px var(--mode-glow); +} + +.mode-box:hover::before { + opacity: 1; +} + +.mode-box:hover::after { + opacity: 0.3; +} + +/* Mode-specific theme colors */ +/* Solo Practice & Quick Match - Primary orange */ +.mode-box.mode-1, +.mode-box.mode-2 { + --mode-color: #F58025; + --mode-glow: rgba(245, 128, 37, 0.35); +} + +/* Create Private & Join Private - Burnt orange/copper */ +.mode-box.mode-3, +.mode-box.mode-4 { + --mode-color: #C86A3A; + --mode-glow: rgba(200, 106, 58, 0.3); } .mode-box h3 { - color: var(--mode-title-color); + color: var(--mode-color); margin-top: 0; - margin-bottom: 1rem; - font-size: 1.5rem; + margin-bottom: 0.6rem; + font-size: 1.4rem; + font-weight: 700; + position: relative; + z-index: 1; + transition: text-shadow 0.35s ease; +} + +.mode-box:hover h3 { + text-shadow: 0 0 20px var(--mode-glow); } .mode-box p { color: var(--mode-text-color); margin: 0; + opacity: 0.85; + font-size: 0.95rem; + line-height: 1.5; + position: relative; + z-index: 1; } +/* Disabled Mode State */ .mode-disabled { - opacity: 0.7; + opacity: 0.5; cursor: not-allowed; } .mode-disabled:hover { transform: none; - box-shadow: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); +} + +.mode-disabled:hover::before, +.mode-disabled:hover::after { + opacity: 0; } .coming-soon-badge { position: absolute; - top: 10px; - right: 10px; - background-color: #333; - color: white; - padding: 0.3rem 0.6rem; - border-radius: 4px; + top: 12px; + right: 12px; + background: linear-gradient(135deg, rgba(50, 50, 50, 0.9), rgba(40, 40, 40, 0.95)); + color: rgba(255, 255, 255, 0.7); + padding: 0.35rem 0.7rem; + border-radius: 6px; font-size: 0.7rem; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.08em; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.1); + z-index: 2; } .mode-box .join-lobby-panel { @@ -69,12 +158,54 @@ display: flex; flex-direction: column; justify-content: center; + position: relative; + z-index: 1; } /* Icon Styling */ .mode-icon { - font-size: 3rem; /* Adjust size as needed */ - color: var(--mode-title-color); /* Match the heading color */ - margin-bottom: 1.5rem; + font-size: 2.75rem; + color: var(--mode-color); + margin-bottom: 1rem; + position: relative; + z-index: 1; + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), + filter 0.35s ease; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); +} + +.mode-box:hover .mode-icon { + transform: scale(1.15) translateY(-4px); + filter: drop-shadow(0 8px 16px var(--mode-glow)); +} + +/* Entrance animations */ +.mode-box { + animation: fade-up 0.5s ease-out both; +} + +.mode-box:nth-child(1) { animation-delay: 0.1s; } +.mode-box:nth-child(2) { animation-delay: 0.2s; } +.mode-box:nth-child(3) { animation-delay: 0.3s; } +.mode-box:nth-child(4) { animation-delay: 0.4s; } + +/* Responsive adjustments */ +@media (max-width: 768px) { + .modes-container { + gap: 1rem; + } + + .mode-box { + min-height: 180px; + padding: 1.25rem; + } + + .mode-icon { + font-size: 2.25rem; + } + + .mode-box h3 { + font-size: 1.2rem; + } } diff --git a/client/src/components/Modes.jsx b/client/src/components/Modes.jsx index 63beba10..4fa8b358 100644 --- a/client/src/components/Modes.jsx +++ b/client/src/components/Modes.jsx @@ -25,7 +25,7 @@ function Modes({ modes }) { const card = (
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/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/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..2487dc91 100644 --- a/client/src/components/Typing.jsx +++ b/client/src/components/Typing.jsx @@ -42,7 +42,7 @@ function Typing({ snippetType, snippetDepartment }) { - const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace(); + const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, markTrustedInteraction } = useRace(); const { socket } = useSocket(); const { user } = useAuth(); const [input, setInput] = useState(''); @@ -630,11 +630,6 @@ function Typing({ e.preventDefault(); return; } - if (e.nativeEvent && e.nativeEvent.isTrusted === false) { - e.preventDefault(); - flagSuspicious('synthetic-beforeinput', { inputType: e.nativeEvent.inputType }); - return; - } markTrustedInteraction(); }; @@ -643,12 +638,6 @@ function Typing({ e.preventDefault(); return; } - const nativeEvent = e.nativeEvent || e; - if (nativeEvent && nativeEvent.isTrusted === false) { - e.preventDefault(); - flagSuspicious('synthetic-keydown', { key: e.key }); - return; - } markTrustedInteraction(); }; @@ -658,12 +647,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 || ''; 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..2c84800b 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -32,9 +32,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 MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this +const MIN_PROGRESS_INTERVAL = 5; // min ms between progress packets +const MAX_ALLOWED_WPM = 350; // anything above is flagged +const MIN_COMPLETION_TIME_MS = 500; // cannot finish faster than this // Store host disconnect timers for private lobbies const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds From acbf28e75dd5a66efe13e9ae1ab8f988e4c0da18 Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 22:20:23 -0500 Subject: [PATCH 4/5] merging master + merge conflict --- client/src/components/Typing.jsx | 46 +++++++++++++++++++++++++++ server/controllers/socket-handlers.js | 37 ++++++++++----------- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx index 2487dc91..873f6a4a 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,11 +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; } + // Don't track beforeinput - some browsers have quirks with isTrusted for deletion events markTrustedInteraction(); }; @@ -638,6 +677,7 @@ function Typing({ e.preventDefault(); return; } + // Don't track keydown - only track onChange for reliable script detection markTrustedInteraction(); }; @@ -656,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/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 2c84800b..6151ddcd 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -31,10 +31,10 @@ 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 = 5; // min ms between progress packets +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 = 500; // cannot finish faster than this +const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this // Store host disconnect timers for private lobbies const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds @@ -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 : []; From 26ffa994cbf299b6af5ad797f0953724e742dbfc Mon Sep 17 00:00:00 2001 From: Ammaar Alam Date: Fri, 5 Dec 2025 22:31:36 -0500 Subject: [PATCH 5/5] pulling main --- client/src/components/Typing.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx index 873f6a4a..e8eb5fe2 100644 --- a/client/src/components/Typing.jsx +++ b/client/src/components/Typing.jsx @@ -42,7 +42,7 @@ function Typing({ snippetType, snippetDepartment }) { - const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, markTrustedInteraction } = useRace(); + const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace(); const { socket } = useSocket(); const { user } = useAuth(); const [input, setInput] = useState('');