Private
Public Access
1
0

chg: dev: massive refactor on front-end for unification and readiness #8

This commit is contained in:
2026-04-21 11:30:07 +02:00
parent 0d04ec91e7
commit 3bbfb8740f
63 changed files with 1096 additions and 480 deletions

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@keyframes appear { @keyframes appear {
from { opacity: 0; transform: scale(0.94); } from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; transform: scale(1); }

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#hero-auth { #hero-auth {
padding: 20px; padding: 20px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.auth-page { .auth-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,200 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
// ── Avatar ───────────────────────────────────────────────────────────────────
.bd-avatar-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
position: relative;
}
.bd-avatar-ring-wrap {
position: relative;
}
.bd-avatar-ring {
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--bd-avatar-gradient);
border: 2px solid var(--bd-avatar-border);
box-shadow: var(--bd-avatar-glow);
display: flex;
align-items: center;
justify-content: center;
font: 800 24px 'Rajdhani', sans-serif;
color: var(--bd-avatar-color);
letter-spacing: 2px;
overflow: hidden;
}
.bd-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bd-avatar-bonus {
position: absolute;
bottom: -6px;
right: -6px;
background: #ffd700;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1);
border: 2px solid rgba(0, 0, 0, 0.5);
z-index: 10;
i {
color: #000;
font-size: 14px;
}
}
.bd-avatar-name {
font: 700 15px 'Rajdhani', sans-serif;
color: var(--bd-avatar-color);
letter-spacing: 1px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.bd-avatar-side {
font: 600 10px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.3);
}
// ── StatRow ──────────────────────────────────────────────────────────────────
.bd-stat-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&__icon {
width: 16px;
color: rgba(149, 207, 245, 0.4);
font-size: 13px;
}
&__label {
font: 500 13px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.45);
flex: 1;
letter-spacing: 0.5px;
}
&__value {
font: 700 13px 'Rajdhani', sans-serif;
color: var(--bd-stat-value-color, rgba(255, 255, 255, 0.75));
letter-spacing: 0.5px;
}
}
// ── BonusPoints ──────────────────────────────────────────────────────────────
.bd-bonus {
padding: 16px 20px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin: 16px 0;
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
&__column {
padding: 16px;
border-radius: 6px;
&--red {
border: 1px solid rgba(173, 10, 5, 0.2);
background: rgba(173, 10, 5, 0.05);
}
&--blue {
border: 1px solid rgba(149, 207, 245, 0.2);
background: rgba(149, 207, 245, 0.05);
}
}
&__heading {
font: 700 12px 'Rajdhani', sans-serif;
text-transform: uppercase;
letter-spacing: 2px;
color: #ffd700;
display: block;
margin-bottom: 12px;
i {
margin-right: 8px;
}
}
&__rows {
display: flex;
flex-direction: column;
}
}
// ── BattleDialog header actions & bonus score row ────────────────────────────
.bd-header-actions {
display: flex;
gap: 8px;
}
.bd-bonus-score {
margin-bottom: 8px;
&__red {
font: 700 13px 'Rajdhani', sans-serif;
color: #f67d52;
display: flex;
align-items: center;
gap: 4px;
i {
font-size: 11px;
}
}
&__blue {
font: 700 13px 'Rajdhani', sans-serif;
color: #95cff5;
display: flex;
align-items: center;
gap: 4px;
i {
font-size: 11px;
}
}
}
.bd-result-badge {
background: var(--bd-result-bg);
border: 1px solid var(--bd-result-border);
color: var(--bd-result-color);
}

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
main div.txt { main div.txt {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
max-width: 900px; max-width: 900px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero-cta { .hero-cta {
position: relative; position: relative;
display: inline-block; display: inline-block;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.feature-block { .feature-block {
width: 100%; width: 100%;
padding: 80px 40px; padding: 80px 40px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
footer { footer {
background: #040608; background: #040608;
border-top: 1px solid rgba(35, 111, 135, 0.12); border-top: 1px solid rgba(35, 111, 135, 0.12);

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
header { header {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero--compact { .hero--compact {
min-height: unset; min-height: unset;
padding: 36px 60px 48px; padding: 36px 60px 48px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.hero { .hero {
position: relative; position: relative;
z-index: 2; z-index: 2;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
* { * {
outline: none; outline: none;
padding: 0; padding: 0;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.hero h1 { .hero h1 {
font-size: 44px; font-size: 44px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
main { main {
background: #07090d; background: #07090d;
} }

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.back-from-game { .back-from-game {
display: inline-block; display: inline-block;
position: fixed; position: fixed;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
html { html {
height: 100%; height: 100%;
padding: 0; padding: 0;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users .user-container .user-control { #mine-wrapper .game-wrapper .users .user-container .user-control {
background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%); background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%);
background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%); background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.5) 0%, rgba(125, 185, 232, 0) 100%);

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .grid { #mine-wrapper .grid {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users .active-mines-container { #mine-wrapper .game-wrapper .users .active-mines-container {
background: -moz-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%); background: -moz-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%);
background: -webkit-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%); background: -webkit-radial-gradient(center, ellipse cover, rgba(255, 252, 252, 1) 0%, rgba(255, 252, 252, 0.99) 1%, rgba(106, 106, 106, 0.39) 61%, rgba(106, 106, 106, 0) 100%);

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .game-overlay { #mine-wrapper .game-wrapper .game-overlay {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
display: flex; display: flex;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
#mine-wrapper .game-wrapper .users { #mine-wrapper .game-wrapper .users {
visibility: hidden; visibility: hidden;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-timer-container { #mine-wrapper .game-timer-container {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
#mine-wrapper .game-wrapper .users { #mine-wrapper .game-wrapper .users {
width: 180px; width: 180px;
padding: 0 10px 0 0; padding: 0 10px 0 0;

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.opd-paper { .opd-paper {
background: #07090d !important; background: #07090d !important;
background-image: linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px), background-image: linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@use "sass:color"; @use "sass:color";
.twofa-status { .twofa-status {

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@use 'homepage/reset'; @use 'homepage/reset';
@use 'homepage/animations'; @use 'homepage/animations';
@use 'homepage/header'; @use 'homepage/header';
@@ -12,4 +21,5 @@
@use 'homepage/tech'; @use 'homepage/tech';
@use 'homepage/footer'; @use 'homepage/footer';
@use 'homepage/profile'; @use 'homepage/profile';
@use 'homepage/battle-dialog';
@use 'homepage/responsive'; @use 'homepage/responsive';

View File

@@ -1,7 +1,7 @@
/*!* /*!*
* This file is part of the SplendidBear Websites' projects. * This file is part of the SplendidBear Websites' projects.
* *
* Copyright (c) 2026 @ www.splendidbear.org * Copyright (c) 2019 @ www.splendidbear.org
* *
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.

View File

@@ -1,7 +1,7 @@
/*!* /*!*
* This file is part of the SplendidBear Websites' projects. * This file is part of the SplendidBear Websites' projects.
* *
* Copyright (c) 2026 @ www.splendidbear.org * Copyright (c) 2019 @ www.splendidbear.org
* *
* For the full copyright and license information, please view the LICENSE * For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code. * file that was distributed with this source code.

View File

@@ -1,3 +1,12 @@
/*!*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2019 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
.mine-beta { .mine-beta {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@@ -1,36 +1,32 @@
import React, { useRef, useState } from 'react'; /**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) { import React, { useMemo, useRef } from 'react';
const [thumbUrl, setThumbUrl] = useState(initialThumbUrl || null); import { string } from 'prop-types';
const [loading, setLoading] = useState(false); import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
const [error, setError] = useState(null);
export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const inputRef = useRef(null); const inputRef = useRef(null);
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
function handleClick() { const handleChange = e => {
inputRef.current?.click();
}
function handleChange(e) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const fd = new FormData(); mutate({ uploadUrl, file }, {
fd.append('avatar', file); onSuccess: data => {
setLoading(true);
setError(null);
fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.error) {
setError(data.error);
return;
}
setThumbUrl(data.thumbUrl); setThumbUrl(data.thumbUrl);
const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)'); const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)');
const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials'); const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials');
if (navImg) { if (navImg) {
navImg.src = data.thumbUrl; navImg.src = data.thumbUrl;
} else if (navInitials) { } else if (navInitials) {
@@ -40,16 +36,17 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
img.className = 'hero-auth-avatar'; img.className = 'hero-auth-avatar';
navInitials.replaceWith(img); navInitials.replaceWith(img);
} }
}) },
.catch(() => setError('Upload failed. Please try again.')) });
.finally(() => setLoading(false)); };
}
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return ( return (
<div <div
className={`profile-avatar${loading ? ' profile-avatar--loading' : ''}`} className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture" title="Click to change profile picture"
onClick={handleClick} onClick={() => inputRef.current?.click()}
> >
{thumbUrl {thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" /> ? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
@@ -65,7 +62,13 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleChange} onChange={handleChange}
/> />
{error && <div className="profile-avatar__error">{error}</div>} {errorMessage && <div className="profile-avatar__error">{errorMessage}</div>}
</div> </div>
); );
} }
AvatarUpload.propTypes = {
uploadUrl: string.isRequired,
initialThumbUrl: string,
initials: string.isRequired,
};

View File

@@ -1,34 +1,21 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { array } from 'prop-types';
import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import Avatar from './battle-dialog/Avatar'; import { Avatar, BonusPoints, StatRow } from '@global-components';
import StatRow from './battle-dialog/StatRow';
import BonusPoints from './battle-dialog/BonusPoints';
const darkTheme = createTheme({ palette: { mode: 'dark' } }); 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 = { const RESULT_META = {
win: { win: {
label: 'Victory', label: 'Victory',
@@ -53,7 +40,7 @@ const RESULT_META = {
}, },
}; };
export default function BattleDialog({ games }) { export const BattleDialog = ({ games }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [game, setGame] = useState(null); const [game, setGame] = useState(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -73,7 +60,7 @@ export default function BattleDialog({ games }) {
}, [games]); }, [games]);
if (!game) { if (!game) {
return <ThemeProvider theme={darkTheme}><Dialog open={false} sx={DIALOG_SX} /></ThemeProvider>; return <ThemeProvider theme={darkTheme}><StyledDialog open={false} /></ThemeProvider>;
} }
const meta = RESULT_META[game.result] ?? RESULT_META.draw; const meta = RESULT_META[game.result] ?? RESULT_META.draw;
@@ -86,18 +73,6 @@ export default function BattleDialog({ games }) {
const canContinue = !resign && 26 > maxPoints; const canContinue = !resign && 26 > maxPoints;
const playUrl = `${window.location.origin}/play/${game.uuid}`; const playUrl = `${window.location.origin}/play/${game.uuid}`;
const formatDuration = (from, to) => {
if (!from || !to) return null;
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
if (isNaN(diffMs) || 0 >= diffMs) return null;
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (0 < h) return `${h}h ${m}m ${s}s`;
if (0 < m) return `${m}m ${s}s`;
return `${s}s`;
};
const duration = formatDuration(game.created, game.date); const duration = formatDuration(game.created, game.date);
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0)); const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52' const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
@@ -113,7 +88,7 @@ export default function BattleDialog({ games }) {
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}> <StyledDialog open={open} onClose={() => setOpen(false)}>
<div className="bd"> <div className="bd">
<div className="bd-header"> <div className="bd-header">
<div className="bd-header-left"> <div className="bd-header-left">
@@ -122,7 +97,7 @@ export default function BattleDialog({ games }) {
<i className="fa fa-crosshairs" /> Match Details <i className="fa fa-crosshairs" /> Match Details
</h2> </h2>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div className="bd-header-actions">
{canContinue ? ( {canContinue ? (
<a <a
className="bd-continue" className="bd-continue"
@@ -160,33 +135,19 @@ export default function BattleDialog({ games }) {
<span className="bd-vs-score__sep">:</span> <span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span> <span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div> </div>
<div className="bd-vs-score" style={{ marginBottom: 8 }}> <div className="bd-vs-score bd-bonus-score">
<span style={{ <span className="bd-bonus-score__red">
font: '700 13px \'Rajdhani\', sans-serif', <i className="fa fa-star" /> {(game.redBonusPoints ?? 0).toFixed(1)}
color: '#f67d52',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
</span> </span>
<span className="bd-vs-score__sep">:</span> <span className="bd-vs-score__sep">:</span>
<span style={{ <span className="bd-bonus-score__blue">
font: '700 13px \'Rajdhani\', sans-serif', {(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" />
color: '#95cff5',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
</span> </span>
</div> </div>
<div className="bd-vs-label">VS</div> <div className="bd-vs-label">VS</div>
<div <div
className="bd-result-badge" className="bd-result-badge"
style={{ background: meta.bg, border: `1px solid ${meta.border}`, color: meta.color }} style={{ '--bd-result-bg': meta.bg, '--bd-result-border': meta.border, '--bd-result-color': meta.color }}
> >
<i className={`fa ${meta.icon}`} /> {meta.label} <i className={`fa ${meta.icon}`} /> {meta.label}
</div> </div>
@@ -226,7 +187,33 @@ export default function BattleDialog({ games }) {
game={game} game={game}
/> />
</div> </div>
</Dialog> </StyledDialog>
</ThemeProvider> </ThemeProvider>
); );
} };
BattleDialog.propTypes = {
games: array.isRequired,
};
const StyledDialog = styled(Dialog)({
'& .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)',
},
});

View File

@@ -8,6 +8,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/** /**
* ContactForm Component * ContactForm Component
@@ -80,4 +81,9 @@ const ContactForm = ({ siteKey, recaptchaFieldId }) => {
return null; return null;
}; };
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm; export default ContactForm;

View File

@@ -8,6 +8,7 @@
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { shape, string } from 'prop-types';
const base64ToArrayBuffer = base64 => { const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -108,3 +109,10 @@ const PasskeyLogin = ({ apiRoutes }) => {
}; };
export default PasskeyLogin; export default PasskeyLogin;
PasskeyLogin.propTypes = {
apiRoutes: shape({
authenticationBegin: string.isRequired,
authenticationComplete: string.isRequired,
}).isRequired,
};

View File

@@ -9,13 +9,15 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import { arrayOf, shape, string, bool } from 'prop-types';
const DIALOG_SX = { const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': { '& .MuiDialog-paper': {
background: '#0a0e14', background: '#0a0e14',
color: '#e0e0e0', color: '#e0e0e0',
@@ -47,7 +49,7 @@ const DIALOG_SX = {
background: 'rgba(2, 4, 8, 0.88)', background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
}, },
}; });
const base64ToArrayBuffer = base64 => { const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/'))); const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -314,7 +316,7 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
)} )}
</div> </div>
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}> <StyledDialog open={addModalOpen} onClose={closeAddModal}>
<DialogTitle>Add New Passkey</DialogTitle> <DialogTitle>Add New Passkey</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -344,9 +346,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Continue Continue
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}> <StyledDialog open={renameModalOpen} onClose={closeRenameModal}>
<DialogTitle>Rename Passkey</DialogTitle> <DialogTitle>Rename Passkey</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
@@ -374,9 +376,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Rename Rename
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}> <StyledDialog open={deleteModalOpen} onClose={closeDeleteModal}>
<DialogTitle>Delete Passkey</DialogTitle> <DialogTitle>Delete Passkey</DialogTitle>
<DialogContent> <DialogContent>
<p> <p>
@@ -402,9 +404,25 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </StyledDialog>
</div> </div>
); );
}; };
export default PasskeyManager; export default PasskeyManager;
PasskeyManager.propTypes = {
credentials: arrayOf(shape({
id: string.isRequired,
credentialName: string.isRequired,
createdAt: string,
lastUsedAt: string,
isBackupEligible: bool,
isBackupAuthenticated: bool,
})).isRequired,
apiRoutes: shape({
credentials: string.isRequired,
registrationBegin: string.isRequired,
registrationComplete: string.isRequired,
}).isRequired,
};

View File

@@ -1,8 +1,18 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react'; import React from 'react';
import { BarChart } from '@mui/x-charts/BarChart'; import { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart'; import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart'; import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, ThemeProvider } from '@mui/material/styles';
import { shape, arrayOf, number, string } from 'prop-types';
const darkTheme = createTheme({ const darkTheme = createTheme({
palette: { palette: {
@@ -136,3 +146,20 @@ export default function ProfileCharts({ chartData }) {
</ThemeProvider> </ThemeProvider>
); );
} }
ProfileCharts.propTypes = {
chartData: shape({
months: arrayOf(string).isRequired,
wins: arrayOf(number).isRequired,
losses: arrayOf(number).isRequired,
draws: arrayOf(number).isRequired,
pieWins: number.isRequired,
pieLosses: number.isRequired,
pieDraws: number.isRequired,
recentGames: shape({
labels: arrayOf(string).isRequired,
mines: arrayOf(number).isRequired,
bonus: arrayOf(number).isRequired,
}).isRequired,
}).isRequired,
};

View File

@@ -7,92 +7,48 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { string } from 'prop-types';
export default function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) { export const Avatar = ({ name, color, avatarUrl, bonusPoints = 0 }) => {
const isRed = 'red' === color; const isRed = 'red' === color;
const initials = (name || '?').slice(0, 2).toUpperCase(); const initials = useMemo(() => (name || '?').slice(0, 2).toUpperCase(), [name]);
const gradient = isRed const cssVars = isRed ? {
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)' '--bd-avatar-gradient': '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%)'; '--bd-avatar-glow': '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)',
const glow = isRed '--bd-avatar-border': 'rgba(173,10,5,0.5)',
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)' '--bd-avatar-color': '#f67d52',
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)'; } : {
const border = isRed '--bd-avatar-gradient': 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)',
? 'rgba(173,10,5,0.5)' '--bd-avatar-glow': '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)',
: 'rgba(35,111,135,0.5)'; '--bd-avatar-border': 'rgba(35,111,135,0.5)',
const textColor = isRed ? '#f67d52' : '#95cff5'; '--bd-avatar-color': '#95cff5',
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}> <div className="bd-avatar-wrap" style={cssVars}>
<div style={{ position: 'relative' }}> <div className="bd-avatar-ring-wrap">
<div style={{ <div className="bd-avatar-ring">
width: 72, height: 72, borderRadius: '50%', {avatarUrl
background: avatarUrl ? 'transparent' : gradient, ? <img src={avatarUrl} alt={name} className="bd-avatar-img" />
border: `2px solid ${border}`, : initials}
boxShadow: glow,
display: 'flex', alignItems: 'center', justifyContent: 'center',
font: '800 24px \'Rajdhani\', sans-serif',
color: textColor,
letterSpacing: 2,
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
initials
)}
</div> </div>
{0 < bonusPoints && ( {0 < bonusPoints && (
<div style={{ <div className="bd-avatar-bonus">
position: 'absolute', <i className="fa fa-star" />
bottom: -6,
right: -6,
background: '#ffd700',
borderRadius: '50%',
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1)',
border: '2px solid rgba(0,0,0,0.5)',
zIndex: 10,
}}
>
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
</div> </div>
)} )}
</div> </div>
<span style={{ <span className="bd-avatar-name">{name}</span>
font: '700 15px \'Rajdhani\', sans-serif', <span className="bd-avatar-side">{isRed ? 'Red' : 'Blue'}</span>
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> </div>
); );
} };
Avatar.propTypes = {
name: string,
color: string,
avatarUrl: string,
bonusPoints: string,
};

View File

@@ -1,7 +1,17 @@
import { useMemo } from 'react'; /**
import StatRow from './StatRow'; * This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export default function BonusPoints({ game }) { import { useMemo } from 'react';
import { StatRow } from './StatRow';
import { object } from 'prop-types';
export const BonusPoints = ({ game }) => {
const hasBonuspoints = useMemo( const hasBonuspoints = useMemo(
() => 0 < game?.redBonusPoints () => 0 < game?.redBonusPoints
|| 0 < game?.blueBonusPoints || 0 < game?.blueBonusPoints
@@ -45,95 +55,68 @@ export default function BonusPoints({ game }) {
], ],
); );
if (!hasBonuspoints) { if (!hasBonuspoints) return '';
return '';
}
return ( return (
<div style={{ <div className="bd-bonus">
padding: '16px 20px 0', <div className="bd-bonus__grid">
borderTop: '1px solid rgba(255,255,255,0.08)', <div className="bd-bonus__column bd-bonus__column--red">
marginTop: 16, <span className="bd-bonus__heading">
marginBottom: 16, <i className="fa fa-star" /> Red Bonus Statistics
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Red Bonus */}
<div style={{
padding: 16,
border: '1px solid rgba(173,10,5,0.2)',
borderRadius: 6,
background: 'rgba(173,10,5,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
</span> </span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div className="bd-bonus__rows">
<StatRow <StatRow icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)} {0 < game.redBonusStats?.blindHits && (
valueColor="#ffd700" <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />
/> )}
{0 < game.redBonusStats?.blindHits {0 < game.redBonusStats?.chainBest && (
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />} <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />
{0 < game.redBonusStats?.chainBest )}
&& <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />} {0 < game.redBonusStats?.edgeMines && (
{0 < game.redBonusStats?.edgeMines <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />} )}
{0 < game.redBonusStats?.lastMineHits {0 < game.redBonusStats?.lastMineHits && (
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />} <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />
{0 < game.redBonusStats?.biggestReveal )}
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />} {0 < game.redBonusStats?.biggestReveal && (
{hasRedNoBonuses <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />} )}
{hasRedNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div> </div>
</div> </div>
<div style={{ <div className="bd-bonus__column bd-bonus__column--blue">
padding: 16, <span className="bd-bonus__heading">
border: '1px solid rgba(149,207,245,0.2)', <i className="fa fa-star" /> Blue Bonus Statistics
borderRadius: 6,
background: 'rgba(149,207,245,0.05)',
}}
>
<span style={{
font: '700 12px \'Rajdhani\', sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
color: '#ffd700',
display: 'block',
marginBottom: 12,
}}
>
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
</span> </span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div className="bd-bonus__rows">
<StatRow <StatRow icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)} {0 < game.blueBonusStats?.blindHits && (
valueColor="#ffd700" <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />
/> )}
{0 < game.blueBonusStats?.blindHits {0 < game.blueBonusStats?.chainBest && (
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />} <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />
{0 < game.blueBonusStats?.chainBest )}
&& <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />} {0 < game.blueBonusStats?.edgeMines && (
{0 < game.blueBonusStats?.edgeMines <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />} )}
{0 < game.blueBonusStats?.lastMineHits {0 < game.blueBonusStats?.lastMineHits && (
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />} <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />
{0 < game.blueBonusStats?.biggestReveal )}
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />} {0 < game.blueBonusStats?.biggestReveal && (
{hasBlueNoBonuses <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />} )}
{hasBlueNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} };
BonusPoints.propTypes = {
game: object.isRequired,
};

View File

@@ -8,33 +8,24 @@
*/ */
import React from 'react'; import React from 'react';
import { node, string } from 'prop-types';
export default function StatRow({ icon, label, value, valueColor }) { export const StatRow = ({ icon, label, value, valueColor }) => (
return ( <div className="bd-stat-row">
<div style={{ <i className={`fa ${icon} bd-stat-row__icon`} />
display: 'flex', alignItems: 'center', <span className="bd-stat-row__label">{label}</span>
gap: 10, padding: '9px 0', <span
borderBottom: '1px solid rgba(255,255,255,0.05)', className="bd-stat-row__value"
}} style={valueColor ? { '--bd-stat-value-color': valueColor } : undefined}
> >
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} /> {value}
<span style={{ </span>
font: '500 13px \'Rajdhani\', sans-serif', </div>
color: 'rgba(255,255,255,0.45)', );
flex: 1,
letterSpacing: 0.5, StatRow.propTypes = {
}} icon: string.isRequired,
> label: string.isRequired,
{label} value: node.isRequired,
</span> valueColor: string,
<span style={{ };
font: '700 13px \'Rajdhani\', sans-serif',
color: valueColor || 'rgba(255,255,255,0.75)',
letterSpacing: 0.5,
}}
>
{value}
</span>
</div>
);
}

View File

@@ -0,0 +1,18 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
export { AvatarUpload } from './AvatarUpload';
export { BattleDialog } from './BattleDialog';
export { default as ContactForm } from './ContactForm';
export { default as PasskeyLogin } from './PasskeyLogin';
export { default as PasskeyManager } from './PasskeyManager';
export { default as ProfileCharts } from './ProfileCharts';
export { BonusPoints } from './battle-dialog/BonusPoints';
export { Avatar } from './battle-dialog/Avatar';
export { StatRow } from './battle-dialog/StatRow';

View File

@@ -9,7 +9,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import ContactForm from './components/ContactForm'; import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper'); const wrapper = document.getElementById('contact-form-wrapper');
@@ -28,4 +28,3 @@ if (wrapper) {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes'); console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
} }
} }

View File

@@ -11,6 +11,7 @@ import React, { useRef } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GameProvider } from '@mine-contexts'; import { GameProvider } from '@mine-contexts';
import { GameBoard } from '@mine-components'; import { GameBoard } from '@mine-components';
import { string } from 'prop-types';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -34,3 +35,9 @@ const MineSeeker = ({ env, gameId, opponentName = '' }) => {
}; };
export default MineSeeker; export default MineSeeker;
MineSeeker.propTypes = {
env: string.isRequired,
gameId: string,
opponentName: string,
};

View File

@@ -8,6 +8,7 @@
*/ */
import React from 'react'; import React from 'react';
import { func, number, string } from 'prop-types';
const BonusBox = ({ color, points, onClick, title }) => ( const BonusBox = ({ color, points, onClick, title }) => (
<button <button
@@ -23,3 +24,10 @@ const BonusBox = ({ color, points, onClick, title }) => (
); );
export default BonusBox; export default BonusBox;
BonusBox.propTypes = {
color: string.isRequired,
points: number.isRequired,
onClick: func.isRequired,
title: string,
};

View File

@@ -9,67 +9,12 @@
import React from 'react'; import React from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { BONUS_LABELS } from '@mine-utils'; import { styled } from '@mui/material/styles';
import { PlayerColumn } from '@mine-components';
const DIALOG_SX = { import { bool, func, shape, string, number, object } from 'prop-types';
'& .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: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatPlayerName = name => {
if (name && name.startsWith('anon_')) {
return 'Anonymous';
}
if (name && 10 < name.length) {
return name.substring(0, 7) + '...';
}
return name || 'Unknown';
};
const PlayerColumn = ({ color, player }) => (
<div className={`bsd-column bsd-column--${color}`}>
<div className="bsd-column-header">
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
<span className="bsd-column-total">
<i className="fa fa-star" />
{player.bonusPoints}
</span>
</div>
<ul className="bsd-stats">
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
<li key={key} className="bsd-stat">
<div className="bsd-stat-text">
<span className="bsd-stat-label">{label}</span>
<span className="bsd-stat-desc">{desc}</span>
</div>
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
</li>
))}
</ul>
</div>
);
const BonusStatsDialog = ({ open, onClose, red, blue }) => ( const BonusStatsDialog = ({ open, onClose, red, blue }) => (
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}> <StyledDialog open={open} onClose={onClose}>
<div className="bsd"> <div className="bsd">
<div className="bsd-header"> <div className="bsd-header">
<div className="bsd-header-text"> <div className="bsd-header-text">
@@ -91,7 +36,44 @@ const BonusStatsDialog = ({ open, onClose, red, blue }) => (
Bonus points are awarded alongside the main score for skillful play. Bonus points are awarded alongside the main score for skillful play.
</p> </p>
</div> </div>
</Dialog> </StyledDialog>
); );
const StyledDialog = styled(Dialog)({
'& .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: '560px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
export default BonusStatsDialog; export default BonusStatsDialog;
BonusStatsDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
red: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
blue: shape({
name: string,
bonusPoints: number,
bonusStats: object,
}).isRequired,
};

View File

@@ -7,7 +7,8 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified'; const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token'; const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
@@ -87,7 +88,7 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
}; };
if (verified) { if (verified) {
return <>{children}</>; return <Fragment>{children}</Fragment>;
} }
return ( return (
@@ -114,3 +115,9 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
}; };
export default CaptchaOverlay; export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
siteKey: string.isRequired,
onVerified: func,
children: node,
};

View File

@@ -7,6 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { func, number } from 'prop-types';
const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => { const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
const [countdown, setCountdown] = useState(seconds); const [countdown, setCountdown] = useState(seconds);
@@ -39,3 +40,9 @@ const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => {
}; };
export default ChallengeCountdown; export default ChallengeCountdown;
ChallengeCountdown.propTypes = {
onAccept: func.isRequired,
onDecline: func.isRequired,
seconds: number,
};

View File

@@ -12,6 +12,7 @@ import { useGame } from '@mine-contexts';
import { useServerCommunication } from '@mine-hooks'; import { useServerCommunication } from '@mine-hooks';
import CaptchaOverlay from './CaptchaOverlay'; import CaptchaOverlay from './CaptchaOverlay';
import GridControl from './grid/GridControl'; import GridControl from './grid/GridControl';
import { bool, string } from 'prop-types';
export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => { export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
const { gridReady } = useGame(); const { gridReady } = useGame();
@@ -42,3 +43,10 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
/> />
); );
}; };
GameBoard.propTypes = {
gameAssoc: string.isRequired,
gameInherited: bool.isRequired,
opponentName: string,
isEnvDev: bool,
};

View File

@@ -7,22 +7,10 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BonusBox, BonusStatsDialog, Avatar } from '@mine-components';
import { formatTime } from '@global-utils/format';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import BonusBox from './BonusBox';
import BonusStatsDialog from './BonusStatsDialog';
const renderAvatar = player => {
if (!player.registered) return null;
return (
<div className="timer-avatar">
{player.avatar
? <img src={player.avatar} alt={player.name} className="timer-avatar__img" />
: <span className="timer-avatar__initials">{player.name.slice(0, 2).toUpperCase()}</span>
}
</div>
);
};
const GameTimer = () => { const GameTimer = () => {
const { overlay, connectionLost, endRef, activePlayer, red, blue } = useGame(); const { overlay, connectionLost, endRef, activePlayer, red, blue } = useGame();
@@ -85,84 +73,61 @@ const GameTimer = () => {
} }
}, [activePlayer, isRunning]); }, [activePlayer, isRunning]);
const syncTimes = useCallback(() => {
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}, [activePlayer]);
useEffect(() => { useEffect(() => {
if (!isRunning) { if (!isRunning) {
if (timerIntervalRef.current) { if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
clearInterval(timerIntervalRef.current);
}
return; return;
} }
timerIntervalRef.current = setInterval(() => { timerIntervalRef.current = setInterval(syncTimes, 100);
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}, 100);
return () => { return () => {
if (timerIntervalRef.current) { if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
clearInterval(timerIntervalRef.current);
}
}; };
}, [isRunning, activePlayer]); }, [isRunning, activePlayer, syncTimes]);
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
if (isRunning) { if (isRunning) syncTimes();
let currentRedTime = pausedRedTimeRef.current;
let currentBlueTime = pausedBlueTimeRef.current;
if (!activePlayer && redStartTimeRef.current) {
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
} else if (activePlayer && blueStartTimeRef.current) {
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
}
setRedTime(currentRedTime);
setBlueTime(currentBlueTime);
}
}; };
window.addEventListener('focus', handleFocus); window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus); return () => window.removeEventListener('focus', handleFocus);
}, [isRunning, activePlayer]); }, [isRunning, activePlayer, syncTimes]);
useEffect(() => () => { useEffect(() => () => {
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current); if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
}, []); }, []);
const formatTime = seconds => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
const openBonusDialog = () => setBonusDialogOpen(true);
const closeBonusDialog = () => setBonusDialogOpen(false);
return ( return (
<div className="game-timer-container"> <div className="game-timer-container">
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={openBonusDialog} /> <BonusBox color="red" points={red.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}> <div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
{renderAvatar(red)} <Avatar player={red} />
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(redTime)}</span> <span className="timer-display">{formatTime(redTime)}</span>
</div> </div>
<div className={`game-timer blue-timer ${activePlayer ? 'active' : ''}`}> <div className={`game-timer blue-timer ${activePlayer ? 'active' : ''}`}>
{renderAvatar(blue)} <Avatar player={blue} />
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} /> <i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
<span className="timer-display">{formatTime(blueTime)}</span> <span className="timer-display">{formatTime(blueTime)}</span>
</div> </div>
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={openBonusDialog} /> <BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={() => setBonusDialogOpen(true)} />
<BonusStatsDialog open={bonusDialogOpen} onClose={closeBonusDialog} red={red} blue={blue} /> <BonusStatsDialog open={bonusDialogOpen} onClose={() => setBonusDialogOpen(false)} red={red} blue={blue} />
</div> </div>
); );
}; };

View File

@@ -8,37 +8,11 @@
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { formatSince } from '@global-utils/format';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import { useLobbyDataProvider } from '@mine-hooks'; import { useLobbyDataProvider } from '@mine-hooks';
import { bool, func, string } from 'prop-types';
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: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
const formatSince = isoStr => {
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
if (1 > diff) return 'just now';
if (1 === diff) return '1 min ago';
return `${diff} min ago`;
};
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => { const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
const [players, setPlayers] = useState([]); const [players, setPlayers] = useState([]);
@@ -171,10 +145,9 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false
} }
return ( return (
<Dialog <StyledDialog
open={open} open={open}
onClose={0 < waitingCountdown ? undefined : onClose} onClose={0 < waitingCountdown ? undefined : onClose}
sx={DIALOG_SX}
> >
<div className="opd"> <div className="opd">
<div className="opd-header"> <div className="opd-header">
@@ -285,8 +258,37 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false
</p> </p>
)} )}
</div> </div>
</Dialog> </StyledDialog>
); );
}; };
const StyledDialog = styled(Dialog)({
'& .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: '500px',
maxWidth: '94vw',
overflow: 'hidden',
color: '#fff',
},
'& .MuiBackdrop-root': {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
});
export default OnlinePlayersDialog; export default OnlinePlayersDialog;
OnlinePlayersDialog.propTypes = {
open: bool.isRequired,
onClose: func.isRequired,
currentGameAssoc: string,
isEnvDev: bool,
};

View File

@@ -8,6 +8,7 @@
*/ */
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { OnlinePlayersDialog } from '@mine-components'; import { OnlinePlayersDialog } from '@mine-components';
import { bool, string } from 'prop-types';
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => { const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -94,3 +95,10 @@ const ShareLinkBox = ({ url }) => {
}; };
export default WaitingOverlayContent; export default WaitingOverlayContent;
WaitingOverlayContent.propTypes = {
shareUrl: string.isRequired,
currentGameAssoc: string,
opponentName: string,
inviteOnly: bool,
};

View File

@@ -13,6 +13,7 @@ import GridField from './GridField';
import UserControl from '../user/UserControl'; import UserControl from '../user/UserControl';
import GameTimer from '../GameTimer'; import GameTimer from '../GameTimer';
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils'; import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
import { func, string } from 'prop-types';
const GridControl = ({ gameAssoc, onClick, resign }) => { const GridControl = ({ gameAssoc, onClick, resign }) => {
const { const {
@@ -61,10 +62,11 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
<div className={`game-overlay${overlay ? '' : ' hide'}`}> <div className={`game-overlay${overlay ? '' : ' hide'}`}>
<div className="game-overlay-window"> <div className="game-overlay-window">
<h1>{overlayTitle}</h1> <h1>{overlayTitle}</h1>
{'string' === typeof overlaySubTitle ? ( {'string' === typeof overlaySubTitle && (
<h2>{overlaySubTitle}</h2> <h2>{overlaySubTitle}</h2>
) : ( )}
overlaySubTitle {'string' !== typeof overlaySubTitle && (
<Fragment>{overlaySubTitle}</Fragment>
)} )}
{gameAssoc && endRef.current && ( {gameAssoc && endRef.current && (
<div className="game-overlay-actions"> <div className="game-overlay-actions">
@@ -113,3 +115,9 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
}; };
export default GridControl; export default GridControl;
GridControl.propTypes = {
gameAssoc: string,
onClick: func.isRequired,
resign: func.isRequired,
};

View File

@@ -9,6 +9,7 @@
import React, { memo, useMemo } from 'react'; import React, { memo, useMemo } from 'react';
import { IMAGES } from '@mine-utils'; import { IMAGES } from '@mine-utils';
import { func, shape, bool, number, string } from 'prop-types';
const bombSrc = area => { const bombSrc = area => {
if (null === area) return null; if (null === area) return null;
@@ -75,3 +76,16 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
}); });
export default GridField; export default GridField;
GridField.propTypes = {
cell: shape({
currentImage: string,
currentObj: string,
active: bool,
lastClickedRed: bool,
lastClickedBlue: bool,
bombTargetArea: number,
}).isRequired,
onClick: func.isRequired,
onMouseEnter: func.isRequired,
};

View File

@@ -16,3 +16,7 @@ export { default as GridControl } from './grid/GridControl';
export { default as GridField } from './grid/GridField'; export { default as GridField } from './grid/GridField';
export { default as User } from './user/User'; export { default as User } from './user/User';
export { default as UserControl } from './user/UserControl'; export { default as UserControl } from './user/UserControl';
export { default as BonusBox } from './BonusBox';
export { default as BonusStatsDialog } from './BonusStatsDialog';
export { Avatar } from './timer/Avatar';
export { PlayerColumn } from './profile/PlayerColumn';

View File

@@ -0,0 +1,52 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { object, string } from 'prop-types';
import { BONUS_LABELS } from '@mine-utils';
const formatPlayerName = name => {
if (name && name.startsWith('anon_')) {
return 'Anonymous';
}
if (name && 10 < name.length) {
return name.substring(0, 7) + '...';
}
return name || 'Unknown';
};
export const PlayerColumn = ({ color, player }) => (
<div className={`bsd-column bsd-column--${color}`}>
<div className="bsd-column-header">
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
<span className="bsd-column-total">
<i className="fa fa-star" />
{player.bonusPoints}
</span>
</div>
<ul className="bsd-stats">
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
<li key={key} className="bsd-stat">
<div className="bsd-stat-text">
<span className="bsd-stat-label">{label}</span>
<span className="bsd-stat-desc">{desc}</span>
</div>
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
</li>
))}
</ul>
</div>
);
PlayerColumn.propTypes = {
color: string.isRequired,
player: object.isRequired,
};

View File

@@ -0,0 +1,28 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { object } from 'prop-types';
export const Avatar = ({ player }) => {
if (!player.registered) return '';
return (
<div className="timer-avatar">
{player.avatar
? <img src={player.avatar} alt={player.name} className="timer-avatar__img" />
: <span className="timer-avatar__initials">{player.name.slice(0, 2).toUpperCase()}</span>
}
</div>
);
};
Avatar.propTypes = {
player: object.isRequired,
};

View File

@@ -9,6 +9,7 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { IMAGES } from '@mine-utils'; import { IMAGES } from '@mine-utils';
import { bool, func, number, string } from 'prop-types';
const User = memo(function User( const User = memo(function User(
{ {
@@ -52,3 +53,15 @@ const User = memo(function User(
}); });
export default User; export default User;
User.propTypes = {
color: string.isRequired,
webPlayer: string,
name: string,
desc: string,
active: bool,
mines: number,
haveBomb: bool,
enabledBomb: bool,
onClickBombSelector: func.isRequired,
};

View File

@@ -11,6 +11,7 @@ import React, { Fragment, useState } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import User from './User'; import User from './User';
import BonusStatsDialog from '../BonusStatsDialog'; import BonusStatsDialog from '../BonusStatsDialog';
import { func } from 'prop-types';
const UserControl = ({ resign }) => { const UserControl = ({ resign }) => {
const { webPlayer, activePlayer, foundMines, red, blue, onBombToggle } = useGame(); const { webPlayer, activePlayer, foundMines, red, blue, onBombToggle } = useGame();
@@ -69,3 +70,7 @@ const UserControl = ({ resign }) => {
} }
export default UserControl; export default UserControl;
UserControl.propTypes = {
resign: func.isRequired,
};

View File

@@ -112,3 +112,21 @@ export const useLobbyDataProvider = () => {
}; };
export default useGameDataProvider; export default useGameDataProvider;
/**
* Profile Data Provider Hook
* Centralized API communication layer for profile-related mutations
*/
export const useProfileDataProvider = () => {
const uploadAvatarMutation = useMutation({
mutationFn: ({ uploadUrl, file }) => {
const fd = new FormData();
fd.append('avatar', file);
return fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => { if (data.error) throw new Error(data.error); return data; });
},
});
return { uploadAvatarMutation };
};

View File

@@ -10,9 +10,8 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useGame } from '@mine-contexts'; import { useGame } from '@mine-contexts';
import { DESC, IMAGES } from '@mine-utils'; import { DESC, IMAGES } from '@mine-utils';
import useStepTimer from './useStepTimer';
import useGameDataProvider from './useGameDataProvider';
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components'; import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
import { useGameDataProvider, useStepTimer } from '@mine-hooks';
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => { const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
const { const {

View File

@@ -10,24 +10,18 @@
import { useRef } from 'react'; import { useRef } from 'react';
const useStepTimer = () => { const useStepTimer = () => {
// Record when the current turn started (timestamp)
const turnStartTimeRef = useRef(null); const turnStartTimeRef = useRef(null);
// Flag to track if we've already recorded a turn start
const turnStartedRef = useRef(false); const turnStartedRef = useRef(false);
const getStepElapsed = (currentActivePlayer, isGameRunning) => { const getStepElapsed = (currentActivePlayer, isGameRunning) => {
// If game not running, return 0
if (!isGameRunning) return 0; if (!isGameRunning) return 0;
// Only initialize the turn timer ONCE per call to getStepElapsed
// This prevents resetting on multiple calls
if (!turnStartedRef.current) { if (!turnStartedRef.current) {
turnStartTimeRef.current = Date.now(); turnStartTimeRef.current = Date.now();
turnStartedRef.current = true; turnStartedRef.current = true;
return 0; return 0;
} }
// After initialization, just calculate elapsed time
if (turnStartTimeRef.current) { if (turnStartTimeRef.current) {
return Math.floor((Date.now() - turnStartTimeRef.current) / 1000); return Math.floor((Date.now() - turnStartTimeRef.current) / 1000);
} }
@@ -40,7 +34,6 @@ const useStepTimer = () => {
turnStartedRef.current = false; turnStartedRef.current = false;
}; };
// Call this when we know a turn has actually changed (from server response)
const startNewTurn = () => { const startNewTurn = () => {
turnStartTimeRef.current = Date.now(); turnStartTimeRef.current = Date.now();
turnStartedRef.current = true; turnStartedRef.current = true;

View File

@@ -9,8 +9,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import PasskeyManager from './components/PasskeyManager'; import { PasskeyLogin, PasskeyManager } from '@global-components';
import PasskeyLogin from './components/PasskeyLogin';
const passkeyManagerRoot = document.getElementById('passkey-manager-root'); const passkeyManagerRoot = document.getElementById('passkey-manager-root');

View File

@@ -1,18 +1,30 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import ProfileCharts from './components/ProfileCharts'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import BattleDialog from './components/BattleDialog'; import { AvatarUpload, BattleDialog, ProfileCharts } from '@global-components';
import AvatarUpload from './components/AvatarUpload';
const queryClient = new QueryClient();
const avatarRoot = document.getElementById('profile-avatar-root'); const avatarRoot = document.getElementById('profile-avatar-root');
if (avatarRoot) { if (avatarRoot) {
const { uploadUrl, thumbUrl, initials } = avatarRoot.dataset; const { uploadUrl, thumbUrl, initials } = avatarRoot.dataset;
createRoot(avatarRoot).render( createRoot(avatarRoot).render(
<AvatarUpload <QueryClientProvider client={queryClient}>
uploadUrl={uploadUrl} <AvatarUpload
initialThumbUrl={thumbUrl || null} uploadUrl={uploadUrl}
initials={initials} initialThumbUrl={thumbUrl || null}
/>, initials={initials}
/>
</QueryClientProvider>,
); );
} }
@@ -36,8 +48,8 @@ if (list && loadMoreBtn) {
const batchSize = parseInt(list.dataset.batchSize, 10) || 5; const batchSize = parseInt(list.dataset.batchSize, 10) || 5;
loadMoreBtn.addEventListener('click', () => { loadMoreBtn.addEventListener('click', () => {
const hidden = list.querySelectorAll('.profile-game--hidden'); const hidden = list.querySelectorAll('.profile-game--hidden');
Array.from(hidden).slice(0, batchSize).forEach((el) => el.classList.remove('profile-game--hidden')); Array.from(hidden).slice(0, batchSize).forEach(el => el.classList.remove('profile-game--hidden'));
if (list.querySelectorAll('.profile-game--hidden').length === 0) { if (0 === list.querySelectorAll('.profile-game--hidden').length) {
loadMoreBtn.remove(); loadMoreBtn.remove();
} }
}); });
@@ -47,10 +59,10 @@ const filterInput = document.querySelector('[data-filter]');
if (list && filterInput) { if (list && filterInput) {
filterInput.addEventListener('input', () => { filterInput.addEventListener('input', () => {
const term = filterInput.value.trim().toLowerCase(); const term = filterInput.value.trim().toLowerCase();
list.classList.toggle('is-filtering', term.length > 0); list.classList.toggle('is-filtering', 0 < term.length);
list.querySelectorAll('.profile-game').forEach((card) => { list.querySelectorAll('.profile-game').forEach(card => {
const opp = card.querySelector('.profile-game__opponent')?.textContent.trim().toLowerCase() ?? ''; const opp = card.querySelector('.profile-game__opponent')?.textContent.trim().toLowerCase() ?? '';
card.classList.toggle('profile-game--filtered-out', term.length > 0 && !opp.includes(term)); card.classList.toggle('profile-game--filtered-out', 0 < term.length && !opp.includes(term));
}); });
}); });
} }

44
assets/js/utils/format.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/** Formats a duration in seconds as MM:SS. */
export const formatTime = seconds => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
/**
* Formats the difference between two 'YYYY-MM-DD HH:mm' strings as a
* human-readable duration (e.g. "1h 4m 23s", "4m 23s", "23s").
* Returns null when the inputs are missing or the diff is not positive.
*/
export const formatDuration = (from, to) => {
if (!from || !to) return null;
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
if (isNaN(diffMs) || 0 >= diffMs) return null;
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (0 < h) return `${h}h ${m}m ${s}s`;
if (0 < m) return `${m}m ${s}s`;
return `${s}s`;
};
/**
* Formats an ISO timestamp as a "X min ago" string (minute resolution).
* Returns 'just now' for differences under one minute.
*/
export const formatSince = isoStr => {
const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000);
if (1 > diff) return 'just now';
if (1 === diff) return '1 min ago';
return `${diff} min ago`;
};

View File

@@ -17,6 +17,8 @@ export default defineConfig({
'@mine-contexts': resolve(__dirname, './assets/js/mine-seeker/contexts'), '@mine-contexts': resolve(__dirname, './assets/js/mine-seeker/contexts'),
'@mine-hooks': resolve(__dirname, './assets/js/mine-seeker/hooks'), '@mine-hooks': resolve(__dirname, './assets/js/mine-seeker/hooks'),
'@mine-utils': resolve(__dirname, './assets/js/mine-seeker/utils'), '@mine-utils': resolve(__dirname, './assets/js/mine-seeker/utils'),
'@global-utils': resolve(__dirname, './assets/js/utils'),
'@global-components': resolve(__dirname, './assets/js/components'),
}, },
}, },
build: { build: {