new: usr: add more stats and a dialog for the recent battle that can be shareable #4
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
.profile-page {
|
.profile-page {
|
||||||
max-width: 760px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 24px 80px;
|
padding: 48px 24px 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -127,6 +127,38 @@
|
|||||||
border-color: rgba(246, 125, 82, 0.45);
|
border-color: rgba(246, 125, 82, 0.45);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--draw {
|
||||||
|
border-color: rgba(149, 207, 245, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(149, 207, 245, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rate {
|
||||||
|
border-color: rgba(168, 130, 255, 0.18);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(168, 130, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--avg {
|
||||||
|
border-color: rgba(80, 200, 220, 0.18);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(80, 200, 220, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--best {
|
||||||
|
border-color: rgba(255, 215, 0, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__icon {
|
.profile-stat__icon {
|
||||||
@@ -145,6 +177,22 @@
|
|||||||
.profile-stat--bomb & {
|
.profile-stat--bomb & {
|
||||||
color: rgba(246, 125, 82, 0.25);
|
color: rgba(246, 125, 82, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stat--draw & {
|
||||||
|
color: rgba(149, 207, 245, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--rate & {
|
||||||
|
color: rgba(168, 130, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--avg & {
|
||||||
|
color: rgba(80, 200, 220, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--best & {
|
||||||
|
color: rgba(255, 215, 0, 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__value {
|
.profile-stat__value {
|
||||||
@@ -164,6 +212,28 @@
|
|||||||
.profile-stat--bomb & {
|
.profile-stat--bomb & {
|
||||||
color: rgba(246, 125, 82, 0.8);
|
color: rgba(246, 125, 82, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stat--draw & {
|
||||||
|
color: #95cff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--rate & {
|
||||||
|
color: #c8a8ff;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.55em;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--avg & {
|
||||||
|
color: #50c8dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--best & {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__label {
|
.profile-stat__label {
|
||||||
@@ -252,10 +322,13 @@
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||||
border-left-width: 3px;
|
border-left-width: 3px;
|
||||||
font: 500 14px 'Rajdhani', sans-serif;
|
font: 500 14px 'Rajdhani', sans-serif;
|
||||||
transition: background 180ms ease;
|
transition: background 180ms ease, border-color 180ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.045);
|
background: rgba(255, 255, 255, 0.055);
|
||||||
|
border-color: rgba(35, 111, 135, 0.35);
|
||||||
|
border-left-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--win {
|
&--win {
|
||||||
@@ -332,6 +405,188 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-charts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart-block {
|
||||||
|
flex: 1 1 300px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px 20px 16px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.profile-section__title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-chart-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
svg text {
|
||||||
|
font-family: 'Rajdhani', sans-serif !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-label {
|
||||||
|
font: 600 10px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: rgba(149, 207, 245, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-title {
|
||||||
|
font: 800 22px 'Rajdhani', sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: rgba(149, 207, 245, 0.5);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-share {
|
||||||
|
background: rgba(35, 111, 135, 0.12);
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(149, 207, 245, 0.7);
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 11px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(35, 111, 135, 0.22);
|
||||||
|
color: #95cff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--copied {
|
||||||
|
background: rgba(42, 158, 96, 0.15);
|
||||||
|
border-color: rgba(42, 158, 96, 0.4);
|
||||||
|
color: #5ee89a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-close {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-vs-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28px 32px 24px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-vs-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-vs-label {
|
||||||
|
font: 800 11px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-vs-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__red, &__blue {
|
||||||
|
font: 800 52px 'Rajdhani', sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__red { color: #f67d52; text-shadow: 0 0 24px rgba(173, 10, 5, 0.5); }
|
||||||
|
&__blue { color: #95cff5; text-shadow: 0 0 24px rgba(35, 111, 135, 0.5); }
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
font: 800 32px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-result-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font: 700 12px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bd-stats {
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-empty {
|
.profile-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 20px;
|
padding: 48px 20px;
|
||||||
@@ -360,3 +615,220 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bshare-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 64px rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
padding: 18px 28px 0;
|
||||||
|
font: 600 10px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: rgba(149, 207, 245, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-vs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font: 700 16px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
font: 600 10px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red .bshare-player__name { color: #f67d52; }
|
||||||
|
&--blue .bshare-player__name { color: #95cff5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font: 800 24px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: linear-gradient(135deg, rgba(173, 10, 5, 0.6) 0%, rgba(246, 125, 82, 0.4) 100%);
|
||||||
|
border: 2px solid rgba(173, 10, 5, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(173, 10, 5, 0.12), 0 0 28px rgba(173, 10, 5, 0.3);
|
||||||
|
color: #f67d52;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: linear-gradient(135deg, rgba(35, 111, 135, 0.6) 0%, rgba(41, 128, 185, 0.4) 100%);
|
||||||
|
border: 2px solid rgba(35, 111, 135, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(35, 111, 135, 0.12), 0 0 28px rgba(35, 111, 135, 0.3);
|
||||||
|
color: #95cff5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-vs__center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-vs__label {
|
||||||
|
font: 800 11px 'Rajdhani', sans-serif;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__red, &__blue {
|
||||||
|
font: 800 56px 'Rajdhani', sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__red { color: #f67d52; text-shadow: 0 0 24px rgba(173, 10, 5, 0.5); }
|
||||||
|
&__blue { color: #95cff5; text-shadow: 0 0 24px rgba(35, 111, 135, 0.5); }
|
||||||
|
|
||||||
|
&__sep {
|
||||||
|
font: 800 32px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.18);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--na {
|
||||||
|
font: 800 40px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font: 700 12px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&--red { background: rgba(173, 10, 5, 0.15); border: 1px solid rgba(173, 10, 5, 0.4); color: #f67d52; }
|
||||||
|
&--blue { background: rgba(35, 111, 135, 0.15); border: 1px solid rgba(35, 111, 135, 0.4); color: #95cff5; }
|
||||||
|
&--draw { background: rgba(149, 207, 245, 0.08); border: 1px solid rgba(149, 207, 245, 0.3); color: #95cff5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-details {
|
||||||
|
padding: 16px 28px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font: 500 13px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
|
||||||
|
i {
|
||||||
|
width: 14px;
|
||||||
|
color: rgba(149, 207, 245, 0.4);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bomb {
|
||||||
|
color: rgba(246, 125, 82, 0.7);
|
||||||
|
|
||||||
|
i { color: rgba(246, 125, 82, 0.5); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-cta {
|
||||||
|
padding: 20px 28px 28px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: 700 13px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
|
||||||
|
background: linear-gradient(to bottom, #ad0a05 0%, #d4401a 55%, #f67d52 100%);
|
||||||
|
border: 1px solid rgba(246, 125, 82, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 20px rgba(173, 10, 5, 0.3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 28px rgba(173, 10, 5, 0.55);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
238
assets/js/components/BattleDialog.jsx
Normal file
238
assets/js/components/BattleDialog.jsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||||
|
|
||||||
|
const darkTheme = createTheme({ palette: { mode: 'dark' } });
|
||||||
|
|
||||||
|
const DIALOG_SX = {
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
background: '#07090d',
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '46px 46px',
|
||||||
|
border: '1px solid rgba(35, 111, 135, 0.4)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0,0,0,0.9)',
|
||||||
|
width: '580px',
|
||||||
|
maxWidth: '94vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
'& .MuiBackdrop-root': {
|
||||||
|
background: 'rgba(2, 4, 8, 0.88)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESULT_META = {
|
||||||
|
win: {
|
||||||
|
label: 'Victory',
|
||||||
|
color: '#5ee89a',
|
||||||
|
bg: 'rgba(42,158,96,0.15)',
|
||||||
|
border: 'rgba(42,158,96,0.4)',
|
||||||
|
icon: 'fa-trophy',
|
||||||
|
},
|
||||||
|
loss: {
|
||||||
|
label: 'Defeat',
|
||||||
|
color: '#f67d52',
|
||||||
|
bg: 'rgba(173,10,5,0.15)',
|
||||||
|
border: 'rgba(173,10,5,0.4)',
|
||||||
|
icon: 'fa-flag',
|
||||||
|
},
|
||||||
|
draw: {
|
||||||
|
label: 'Draw',
|
||||||
|
color: '#95cff5',
|
||||||
|
bg: 'rgba(149,207,245,0.1)',
|
||||||
|
border: 'rgba(149,207,245,0.3)',
|
||||||
|
icon: 'fa-minus',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function Avatar({ name, color }) {
|
||||||
|
const isRed = 'red' === color;
|
||||||
|
const initials = (name || '?').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
const gradient = isRed
|
||||||
|
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
|
||||||
|
const glow = isRed
|
||||||
|
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
|
||||||
|
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
|
||||||
|
const border = isRed
|
||||||
|
? 'rgba(173,10,5,0.5)'
|
||||||
|
: 'rgba(35,111,135,0.5)';
|
||||||
|
const textColor = isRed ? '#f67d52' : '#95cff5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 72, borderRadius: '50%',
|
||||||
|
background: gradient,
|
||||||
|
border: `2px solid ${border}`,
|
||||||
|
boxShadow: glow,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
font: '800 24px \'Rajdhani\', sans-serif',
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
font: '700 15px \'Rajdhani\', sans-serif',
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 1,
|
||||||
|
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
font: '600 10px \'Rajdhani\', sans-serif',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: 'rgba(255,255,255,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRed ? 'Red' : 'Blue'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({ icon, label, value, valueColor }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
gap: 10, padding: '9px 0',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
|
||||||
|
<span style={{
|
||||||
|
font: '500 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: 'rgba(255,255,255,0.45)',
|
||||||
|
flex: 1,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
font: '700 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: valueColor || 'rgba(255,255,255,0.75)',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BattleDialog({ games }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [game, setGame] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = e => {
|
||||||
|
const row = e.target.closest('[data-game-index]');
|
||||||
|
if (!row) return;
|
||||||
|
const idx = parseInt(row.dataset.gameIndex, 10);
|
||||||
|
if (!isNaN(idx) && games[idx]) {
|
||||||
|
setGame(games[idx]);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
return () => document.removeEventListener('click', handler);
|
||||||
|
}, [games]);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
|
||||||
|
const resign = game.resign;
|
||||||
|
const endReason = resign
|
||||||
|
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
||||||
|
: 'Points';
|
||||||
|
const shareUrl = `${window.location.origin}/battle/${game.id}`;
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}>
|
||||||
|
<div className="bd">
|
||||||
|
<div className="bd-header">
|
||||||
|
<div className="bd-header-left">
|
||||||
|
<span className="bd-label">Battle Report</span>
|
||||||
|
<h2 className="bd-title">
|
||||||
|
<i className="fa fa-crosshairs" /> Match Details
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
||||||
|
onClick={handleShare}
|
||||||
|
aria-label="Copy share link"
|
||||||
|
title="Copy share link"
|
||||||
|
>
|
||||||
|
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||||
|
{copied ? 'Copied!' : 'Share'}
|
||||||
|
</button>
|
||||||
|
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bd-vs-panel">
|
||||||
|
<Avatar name={game.redName} color="red" />
|
||||||
|
<div className="bd-vs-center">
|
||||||
|
<div className="bd-vs-score">
|
||||||
|
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
||||||
|
<span className="bd-vs-score__sep">:</span>
|
||||||
|
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bd-vs-label">VS</div>
|
||||||
|
<div
|
||||||
|
className="bd-result-badge"
|
||||||
|
style={{ background: meta.bg, border: `1px solid ${meta.border}`, color: meta.color }}
|
||||||
|
>
|
||||||
|
<i className={`fa ${meta.icon}`} /> {meta.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Avatar name={game.blueName} color="blue" />
|
||||||
|
</div>
|
||||||
|
<div className="bd-stats">
|
||||||
|
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
||||||
|
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
|
||||||
|
<StatRow
|
||||||
|
icon="fa-bomb" label="Red hit a mine"
|
||||||
|
value={game.redExplodedBomb ? 'Yes' : 'No'}
|
||||||
|
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
|
||||||
|
/>
|
||||||
|
<StatRow
|
||||||
|
icon="fa-bomb" label="Blue hit a mine"
|
||||||
|
value={game.blueExplodedBomb ? 'Yes' : 'No'}
|
||||||
|
valueColor={game.blueExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
|
||||||
|
/>
|
||||||
|
{game.created && game.date && game.created !== game.date && (
|
||||||
|
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
assets/js/components/ProfileCharts.jsx
Normal file
103
assets/js/components/ProfileCharts.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BarChart } from '@mui/x-charts/BarChart';
|
||||||
|
import { PieChart } from '@mui/x-charts/PieChart';
|
||||||
|
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||||
|
|
||||||
|
const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
background: { paper: 'transparent' },
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '\'Rajdhani\', sans-serif',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const WIN_COLOR = '#5ee89a';
|
||||||
|
const LOSS_COLOR = '#f67d52';
|
||||||
|
const DRAW_COLOR = '#95cff5';
|
||||||
|
|
||||||
|
const axisStyle = {
|
||||||
|
tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
|
||||||
|
labelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileCharts({ chartData }) {
|
||||||
|
const { months, wins, losses, draws, pieWins, pieLosses, pieDraws } = chartData;
|
||||||
|
const total = pieWins + pieLosses + pieDraws;
|
||||||
|
|
||||||
|
const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
<div className="profile-charts">
|
||||||
|
{0 < total && (
|
||||||
|
<div className="profile-chart-block">
|
||||||
|
<h2 className="profile-section__title">
|
||||||
|
<i className="fa fa-pie-chart" /> Result Breakdown
|
||||||
|
</h2>
|
||||||
|
<div className="profile-chart-inner">
|
||||||
|
<PieChart
|
||||||
|
series={[{
|
||||||
|
data: [
|
||||||
|
{ id: 0, value: pieWins, label: `Wins ${pieWins}`, color: WIN_COLOR },
|
||||||
|
{ id: 1, value: pieLosses, label: `Losses ${pieLosses}`, color: LOSS_COLOR },
|
||||||
|
{ id: 2, value: pieDraws, label: `Draws ${pieDraws}`, color: DRAW_COLOR },
|
||||||
|
],
|
||||||
|
innerRadius: 52,
|
||||||
|
paddingAngle: 3,
|
||||||
|
cornerRadius: 4,
|
||||||
|
highlightScope: { fade: 'global', highlight: 'item' },
|
||||||
|
}]}
|
||||||
|
slotProps={{
|
||||||
|
legend: {
|
||||||
|
labelStyle: {
|
||||||
|
fill: 'rgba(255,255,255,0.55)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '\'Rajdhani\', sans-serif',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
width={320}
|
||||||
|
height={200}
|
||||||
|
margin={{ top: 10, bottom: 10, left: 10, right: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasBars && (
|
||||||
|
<div className="profile-chart-block">
|
||||||
|
<h2 className="profile-section__title">
|
||||||
|
<i className="fa fa-bar-chart" /> Activity (6 months)
|
||||||
|
</h2>
|
||||||
|
<div className="profile-chart-inner">
|
||||||
|
<BarChart
|
||||||
|
xAxis={[{ scaleType: 'band', data: months, ...axisStyle }]}
|
||||||
|
yAxis={[{ ...axisStyle }]}
|
||||||
|
series={[
|
||||||
|
{ data: wins, label: 'Wins', color: WIN_COLOR, stack: 'total' },
|
||||||
|
{ data: losses, label: 'Losses', color: LOSS_COLOR, stack: 'total' },
|
||||||
|
{ data: draws, label: 'Draws', color: DRAW_COLOR, stack: 'total' },
|
||||||
|
]}
|
||||||
|
slotProps={{
|
||||||
|
legend: {
|
||||||
|
labelStyle: {
|
||||||
|
fill: 'rgba(255,255,255,0.55)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '\'Rajdhani\', sans-serif',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
borderRadius={3}
|
||||||
|
width={460}
|
||||||
|
height={200}
|
||||||
|
margin={{ top: 10, bottom: 30, left: 30, right: 120 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
assets/js/profile.jsx
Normal file
18
assets/js/profile.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import ProfileCharts from './components/ProfileCharts';
|
||||||
|
import BattleDialog from './components/BattleDialog';
|
||||||
|
|
||||||
|
const chartsRoot = document.getElementById('profile-charts-root');
|
||||||
|
if (chartsRoot) {
|
||||||
|
createRoot(chartsRoot).render(
|
||||||
|
<ProfileCharts chartData={JSON.parse(chartsRoot.dataset.chartData)} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const battleRoot = document.getElementById('profile-battle-root');
|
||||||
|
if (battleRoot) {
|
||||||
|
createRoot(battleRoot).render(
|
||||||
|
<BattleDialog games={JSON.parse(battleRoot.dataset.games)} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
5442
package-lock.json
generated
Normal file
5442
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fortawesome/fontawesome-free": "^5.2.0",
|
"@fortawesome/fontawesome-free": "^5.2.0",
|
||||||
"@mui/material": "^9.0.0",
|
"@mui/material": "^9.0.0",
|
||||||
|
"@mui/x-charts": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"howler": "^2.1.2",
|
"howler": "^2.1.2",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
|
|||||||
@@ -45,14 +45,147 @@ class ProfileController extends AbstractController
|
|||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
|
$total = $this->repo->countFinishedForUser($user);
|
||||||
|
$wins = $this->repo->countWinsForUser($user);
|
||||||
|
$losses = $this->repo->countLossesForUser($user);
|
||||||
|
$draws = $this->repo->countDrawsForUser($user);
|
||||||
|
|
||||||
|
// Build monthly buckets for the last 6 months
|
||||||
|
$monthlyData = [];
|
||||||
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
|
$dt = new \DateTime("first day of -$i months midnight");
|
||||||
|
$key = $dt->format('Y-m');
|
||||||
|
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$since = new \DateTime('first day of -5 months midnight');
|
||||||
|
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
|
||||||
|
$userId = $user->getId();
|
||||||
|
|
||||||
|
foreach ($recentGames as $game) {
|
||||||
|
if (!$game->getUpdated()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$month = $game->getUpdated()->format('Y-m');
|
||||||
|
if (!isset($monthlyData[$month])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
|
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
|
||||||
|
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
|
||||||
|
$resign = $game->getResign();
|
||||||
|
$myColor = $isRed ? 'red' : 'blue';
|
||||||
|
$oppColor = $isRed ? 'blue' : 'red';
|
||||||
|
|
||||||
|
$result = 'draws';
|
||||||
|
if ($resign === $myColor) {
|
||||||
|
$result = 'losses';
|
||||||
|
} elseif ($resign === $oppColor) {
|
||||||
|
$result = 'wins';
|
||||||
|
} elseif ($myPts !== null && $oppPts !== null) {
|
||||||
|
if ($myPts > $oppPts) $result = 'wins';
|
||||||
|
elseif ($myPts < $oppPts) $result = 'losses';
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthlyData[$month][$result]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = array_column(array_values($monthlyData), 'label');
|
||||||
|
|
||||||
return $this->render('Security/profile.html.twig', [
|
return $this->render('Security/profile.html.twig', [
|
||||||
'stats' => [
|
'stats' => [
|
||||||
'total' => $this->repo->countFinishedForUser($user),
|
'total' => $total,
|
||||||
'wins' => $this->repo->countWinsForUser($user),
|
'wins' => $wins,
|
||||||
'losses' => $this->repo->countLossesForUser($user),
|
'losses' => $losses,
|
||||||
|
'draws' => $draws,
|
||||||
'bombs' => $this->repo->countBombsForUser($user),
|
'bombs' => $this->repo->countBombsForUser($user),
|
||||||
|
'winRate' => $total > 0 ? (int) round($wins / $total * 100) : 0,
|
||||||
|
'avgScore' => $this->repo->findAvgScoreForUser($user),
|
||||||
|
'bestScore' => $this->repo->findBestScoreForUser($user),
|
||||||
],
|
],
|
||||||
'recent' => $this->repo->findRecentFinishedForUser($user),
|
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
||||||
|
'gamesData' => array_map(static function (\App\Entity\PlayedGame $game) use ($userId): array {
|
||||||
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
|
$resign = $game->getResign();
|
||||||
|
$myColor = $isRed ? 'red' : 'blue';
|
||||||
|
$oppColor = $isRed ? 'blue' : 'red';
|
||||||
|
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
|
||||||
|
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
|
||||||
|
|
||||||
|
$result = 'draw';
|
||||||
|
if ($resign === $myColor) $result = 'loss';
|
||||||
|
elseif ($resign === $oppColor) $result = 'win';
|
||||||
|
elseif ($myPts !== null && $oppPts !== null) {
|
||||||
|
if ($myPts > $oppPts) $result = 'win';
|
||||||
|
elseif ($myPts < $oppPts) $result = 'loss';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $game->getId(),
|
||||||
|
'redName' => $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
|
||||||
|
'blueName' => $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
|
||||||
|
'redPoints' => $game->getRedPoints(),
|
||||||
|
'bluePoints' => $game->getBluePoints(),
|
||||||
|
'redExplodedBomb' => $game->getRedExplodedBomb(),
|
||||||
|
'blueExplodedBomb' => $game->getBlueExplodedBomb(),
|
||||||
|
'resign' => $resign,
|
||||||
|
'created' => $game->getCreated()?->format('Y-m-d H:i'),
|
||||||
|
'date' => $game->getUpdated()?->format('Y-m-d H:i'),
|
||||||
|
'isRed' => $isRed,
|
||||||
|
'result' => $result,
|
||||||
|
'myPoints' => $myPts,
|
||||||
|
'oppPoints' => $oppPts,
|
||||||
|
];
|
||||||
|
}, $recent),
|
||||||
|
'chartData' => [
|
||||||
|
'months' => $months,
|
||||||
|
'wins' => array_column(array_values($monthlyData), 'wins'),
|
||||||
|
'losses' => array_column(array_values($monthlyData), 'losses'),
|
||||||
|
'draws' => array_column(array_values($monthlyData), 'draws'),
|
||||||
|
'pieWins' => $wins,
|
||||||
|
'pieLosses' => $losses,
|
||||||
|
'pieDraws' => $draws,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/battle/{id}', name: 'MineSeekerBundle_battle_share', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||||
|
public function battleShare(int $id): Response
|
||||||
|
{
|
||||||
|
$game = $this->repo->find($id);
|
||||||
|
if (!$game) {
|
||||||
|
throw $this->createNotFoundException('Battle not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest';
|
||||||
|
$blueName = $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest';
|
||||||
|
$redPts = $game->getRedPoints();
|
||||||
|
$bluePts = $game->getBluePoints();
|
||||||
|
$resign = $game->getResign();
|
||||||
|
|
||||||
|
if ($resign === 'red') {
|
||||||
|
$summary = "$redName resigned — $blueName wins";
|
||||||
|
} elseif ($resign === 'blue') {
|
||||||
|
$summary = "$blueName resigned — $redName wins";
|
||||||
|
} elseif ($redPts !== null && $bluePts !== null) {
|
||||||
|
if ($redPts > $bluePts) $summary = "$redName defeated $blueName ($redPts – $bluePts)";
|
||||||
|
elseif ($bluePts > $redPts) $summary = "$blueName defeated $redName ($bluePts – $redPts)";
|
||||||
|
else $summary = "$redName and $blueName drew ($redPts – $bluePts)";
|
||||||
|
} else {
|
||||||
|
$summary = "$redName vs $blueName";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('Game/battle_share.html.twig', [
|
||||||
|
'game' => $game,
|
||||||
|
'redName' => $redName,
|
||||||
|
'blueName' => $blueName,
|
||||||
|
'redPts' => $redPts,
|
||||||
|
'bluePts' => $bluePts,
|
||||||
|
'resign' => $resign,
|
||||||
|
'ogTitle' => "MineSeeker · $summary",
|
||||||
|
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,108 @@ class PlayedGameRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function countDrawsForUser(User $user): int
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('g');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (int) $qb
|
||||||
|
->select('COUNT(g.id)')
|
||||||
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->orX(
|
||||||
|
$qb->expr()->eq('g.red', ':u'),
|
||||||
|
$qb->expr()->eq('g.blue', ':u'),
|
||||||
|
),
|
||||||
|
$qb->expr()->isNotNull('g.redPoints'),
|
||||||
|
$qb->expr()->isNotNull('g.bluePoints'),
|
||||||
|
$qb->expr()->isNull('g.resign'),
|
||||||
|
'g.redPoints = g.bluePoints',
|
||||||
|
))
|
||||||
|
->setParameter('u', $user)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
} catch (NoResultException | NonUniqueResultException $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAvgScoreForUser(User $user): int
|
||||||
|
{
|
||||||
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
|
|
||||||
|
$result = $conn->executeQuery(
|
||||||
|
'SELECT
|
||||||
|
SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END) AS total_pts,
|
||||||
|
COUNT(g.id) AS total_games
|
||||||
|
FROM played_game g
|
||||||
|
WHERE (g.red_id = :uid OR g.blue_id = :uid)
|
||||||
|
AND (
|
||||||
|
(g.red_id = :uid AND g.red_points IS NOT NULL)
|
||||||
|
OR (g.blue_id = :uid AND g.blue_points IS NOT NULL)
|
||||||
|
)',
|
||||||
|
['uid' => $user->getId()],
|
||||||
|
)->fetchAssociative();
|
||||||
|
|
||||||
|
if (!$result || (int) $result['total_games'] === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round((float) $result['total_pts'] / (int) $result['total_games']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findBestScoreForUser(User $user): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$qbRed = $this->createQueryBuilder('g');
|
||||||
|
$maxRed = (int) $qbRed
|
||||||
|
->select('MAX(g.redPoints)')
|
||||||
|
->where($qbRed->expr()->eq('g.red', ':u'))
|
||||||
|
->setParameter('u', $user)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
|
||||||
|
$qbBlue = $this->createQueryBuilder('g');
|
||||||
|
$maxBlue = (int) $qbBlue
|
||||||
|
->select('MAX(g.bluePoints)')
|
||||||
|
->where($qbBlue->expr()->eq('g.blue', ':u'))
|
||||||
|
->setParameter('u', $user)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
|
||||||
|
return max($maxRed, $maxBlue);
|
||||||
|
} catch (NoResultException | NonUniqueResultException $e) {
|
||||||
|
$this->logger->error($e->getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PlayedGame[]
|
||||||
|
*/
|
||||||
|
public function findFinishedForUserSince(User $user, DateTime $since): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('g');
|
||||||
|
|
||||||
|
return $qb
|
||||||
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->orX(
|
||||||
|
$qb->expr()->eq('g.red', ':u'),
|
||||||
|
$qb->expr()->eq('g.blue', ':u'),
|
||||||
|
),
|
||||||
|
$qb->expr()->orX(
|
||||||
|
$qb->expr()->isNotNull('g.redPoints'),
|
||||||
|
$qb->expr()->isNotNull('g.resign'),
|
||||||
|
),
|
||||||
|
$qb->expr()->gte('g.updated', ':since'),
|
||||||
|
))
|
||||||
|
->setParameter('u', $user)
|
||||||
|
->setParameter('since', $since)
|
||||||
|
->orderBy('g.updated', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return PlayedGame[]
|
* @return PlayedGame[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
128
templates/Game/battle_share.html.twig
Normal file
128
templates/Game/battle_share.html.twig
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %} - Battle Report{% endblock %}
|
||||||
|
|
||||||
|
{% block metas %}
|
||||||
|
{% set shareUrl = url('MineSeekerBundle_battle_share', { id: game.id }) %}
|
||||||
|
<meta property="og:url" content="{{ shareUrl }}"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
<meta property="og:title" content="{{ ogTitle }}"/>
|
||||||
|
<meta property="og:description" content="{{ ogDesc }}"/>
|
||||||
|
<meta property="og:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/>
|
||||||
|
<meta property="og:image:width" content="1600"/>
|
||||||
|
<meta property="og:image:height" content="627"/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content="{{ ogTitle }}"/>
|
||||||
|
<meta name="twitter:description" content="{{ ogDesc }}"/>
|
||||||
|
<meta name="twitter:image" content="{{ app.request.getSchemeAndHttpHost() }}{{ asset('images/mine-1600x627.png') }}"/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="bshare-page">
|
||||||
|
|
||||||
|
<div class="bshare-card">
|
||||||
|
|
||||||
|
<div class="bshare-card__eyebrow">
|
||||||
|
<i class="fa fa-crosshairs"></i> Battle Report
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# VS Header #}
|
||||||
|
<div class="bshare-vs">
|
||||||
|
|
||||||
|
<div class="bshare-player bshare-player--red">
|
||||||
|
<div class="bshare-avatar bshare-avatar--red">
|
||||||
|
{{ redName|slice(0,2)|upper }}
|
||||||
|
</div>
|
||||||
|
<span class="bshare-player__name">{{ redName }}</span>
|
||||||
|
<span class="bshare-player__side">Red</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bshare-vs__center">
|
||||||
|
{% if redPts is not null and bluePts is not null %}
|
||||||
|
<div class="bshare-score">
|
||||||
|
<span class="bshare-score__red">{{ redPts }}</span>
|
||||||
|
<span class="bshare-score__sep">:</span>
|
||||||
|
<span class="bshare-score__blue">{{ bluePts }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bshare-score bshare-score--na">— : —</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="bshare-vs__label">VS</div>
|
||||||
|
|
||||||
|
{# Result badge #}
|
||||||
|
{% if resign == 'red' %}
|
||||||
|
<div class="bshare-badge bshare-badge--blue">
|
||||||
|
<i class="fa fa-trophy"></i> Blue wins
|
||||||
|
</div>
|
||||||
|
{% elseif resign == 'blue' %}
|
||||||
|
<div class="bshare-badge bshare-badge--red">
|
||||||
|
<i class="fa fa-trophy"></i> Red wins
|
||||||
|
</div>
|
||||||
|
{% elseif redPts is not null and bluePts is not null %}
|
||||||
|
{% if redPts > bluePts %}
|
||||||
|
<div class="bshare-badge bshare-badge--red">
|
||||||
|
<i class="fa fa-trophy"></i> Red wins
|
||||||
|
</div>
|
||||||
|
{% elseif bluePts > redPts %}
|
||||||
|
<div class="bshare-badge bshare-badge--blue">
|
||||||
|
<i class="fa fa-trophy"></i> Blue wins
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bshare-badge bshare-badge--draw">
|
||||||
|
<i class="fa fa-minus"></i> Draw
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bshare-player bshare-player--blue">
|
||||||
|
<div class="bshare-avatar bshare-avatar--blue">
|
||||||
|
{{ blueName|slice(0,2)|upper }}
|
||||||
|
</div>
|
||||||
|
<span class="bshare-player__name">{{ blueName }}</span>
|
||||||
|
<span class="bshare-player__side">Blue</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Details #}
|
||||||
|
<div class="bshare-details">
|
||||||
|
{% if resign %}
|
||||||
|
<div class="bshare-detail">
|
||||||
|
<i class="fa fa-flag"></i>
|
||||||
|
<span>{{ resign|capitalize }} resigned</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.redExplodedBomb %}
|
||||||
|
<div class="bshare-detail bshare-detail--bomb">
|
||||||
|
<i class="fa fa-bomb"></i>
|
||||||
|
<span>{{ redName }} hit a mine</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.blueExplodedBomb %}
|
||||||
|
<div class="bshare-detail bshare-detail--bomb">
|
||||||
|
<i class="fa fa-bomb"></i>
|
||||||
|
<span>{{ blueName }} hit a mine</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if game.updated %}
|
||||||
|
<div class="bshare-detail">
|
||||||
|
<i class="fa fa-calendar"></i>
|
||||||
|
<span>{{ game.updated|date('Y-m-d H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bshare-cta">
|
||||||
|
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
|
||||||
|
<i class="fa fa-play"></i> Play MineSeeker
|
||||||
|
</a>
|
||||||
|
<a href="{{ path('MineSeekerBundle_homepage') }}" class="bshare-btn bshare-btn--ghost">
|
||||||
|
<i class="fa fa-home"></i> Homepage
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -38,6 +38,26 @@
|
|||||||
<span class="profile-stat__value">{{ stats.losses }}</span>
|
<span class="profile-stat__value">{{ stats.losses }}</span>
|
||||||
<span class="profile-stat__label">Defeats</span>
|
<span class="profile-stat__label">Defeats</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--draw">
|
||||||
|
<i class="fa fa-minus profile-stat__icon"></i>
|
||||||
|
<span class="profile-stat__value">{{ stats.draws }}</span>
|
||||||
|
<span class="profile-stat__label">Draws</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--rate">
|
||||||
|
<i class="fa fa-percent profile-stat__icon"></i>
|
||||||
|
<span class="profile-stat__value">{{ stats.winRate }}<small>%</small></span>
|
||||||
|
<span class="profile-stat__label">Win rate</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--avg">
|
||||||
|
<i class="fa fa-line-chart profile-stat__icon"></i>
|
||||||
|
<span class="profile-stat__value">{{ stats.avgScore }}</span>
|
||||||
|
<span class="profile-stat__label">Avg score</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--best">
|
||||||
|
<i class="fa fa-star profile-stat__icon"></i>
|
||||||
|
<span class="profile-stat__value">{{ stats.bestScore }}</span>
|
||||||
|
<span class="profile-stat__label">Best score</span>
|
||||||
|
</div>
|
||||||
<div class="profile-stat profile-stat--bomb">
|
<div class="profile-stat profile-stat--bomb">
|
||||||
<i class="fa fa-bomb profile-stat__icon"></i>
|
<i class="fa fa-bomb profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.bombs }}</span>
|
<span class="profile-stat__value">{{ stats.bombs }}</span>
|
||||||
@@ -45,6 +65,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if stats.total > 0 %}
|
||||||
|
<div id="profile-charts-root" data-chart-data="{{ chartData|json_encode|e('html') }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="profile-battle-root" data-games="{{ gamesData|json_encode|e('html') }}"></div>
|
||||||
|
|
||||||
{% if recent|length > 0 %}
|
{% if recent|length > 0 %}
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h2 class="profile-section__title">
|
<h2 class="profile-section__title">
|
||||||
@@ -71,7 +97,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="profile-game profile-game--{{ result }}">
|
<div class="profile-game profile-game--{{ result }}" data-game-index="{{ loop.index0 }}">
|
||||||
<span class="profile-game__badge">
|
<span class="profile-game__badge">
|
||||||
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }}
|
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -106,3 +132,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ vite_entry_script_tags('profile') }}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
mineseeker: './assets/js/app.jsx',
|
mineseeker: './assets/js/app.jsx',
|
||||||
passkey: './assets/js/passkey.jsx',
|
passkey: './assets/js/passkey.jsx',
|
||||||
|
profile: './assets/js/profile.jsx',
|
||||||
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
mineseekerStyle: './assets/css/style.mineseeker.scss',
|
||||||
homeStyle: './assets/css/style.layout.scss',
|
homeStyle: './assets/css/style.layout.scss',
|
||||||
passkeyStyle: './assets/css/passkey.scss',
|
passkeyStyle: './assets/css/passkey.scss',
|
||||||
|
|||||||
Reference in New Issue
Block a user