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,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 }) {
const [thumbUrl, setThumbUrl] = useState(initialThumbUrl || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
import React, { useMemo, useRef } from 'react';
import { string } from 'prop-types';
import { useProfileDataProvider } from '@mine-hooks/useGameDataProvider';
export const AvatarUpload = ({ uploadUrl, initialThumbUrl, initials }) => {
const inputRef = useRef(null);
const [thumbUrl, setThumbUrl] = React.useState(initialThumbUrl || null);
const { uploadAvatarMutation: { isPending, error, mutate } } = useProfileDataProvider();
function handleClick() {
inputRef.current?.click();
}
function handleChange(e) {
const handleChange = e => {
const file = e.target.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('avatar', file);
setLoading(true);
setError(null);
fetch(uploadUrl, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.error) {
setError(data.error);
return;
}
mutate({ uploadUrl, file }, {
onSuccess: data => {
setThumbUrl(data.thumbUrl);
const navImg = document.querySelector('.hero-auth-avatar:not(.hero-auth-avatar--initials)');
const navInitials = document.querySelector('.hero-auth-avatar.hero-auth-avatar--initials');
if (navImg) {
navImg.src = data.thumbUrl;
} else if (navInitials) {
@@ -40,16 +36,17 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
img.className = 'hero-auth-avatar';
navInitials.replaceWith(img);
}
})
.catch(() => setError('Upload failed. Please try again.'))
.finally(() => setLoading(false));
}
},
});
};
const errorMessage = useMemo(() => error?.message ?? null, [error]);
return (
<div
className={`profile-avatar${loading ? ' profile-avatar--loading' : ''}`}
className={`profile-avatar${isPending ? ' profile-avatar--loading' : ''}`}
title="Click to change profile picture"
onClick={handleClick}
onClick={() => inputRef.current?.click()}
>
{thumbUrl
? <img src={thumbUrl} alt={initials} className="profile-avatar__img" />
@@ -65,7 +62,13 @@ export default function AvatarUpload({ uploadUrl, initialThumbUrl, initials }) {
style={{ display: 'none' }}
onChange={handleChange}
/>
{error && <div className="profile-avatar__error">{error}</div>}
{errorMessage && <div className="profile-avatar__error">{errorMessage}</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 { array } from 'prop-types';
import { formatDuration } from '@global-utils/format';
import Dialog from '@mui/material/Dialog';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import Avatar from './battle-dialog/Avatar';
import StatRow from './battle-dialog/StatRow';
import BonusPoints from './battle-dialog/BonusPoints';
import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { Avatar, BonusPoints, StatRow } from '@global-components';
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',
@@ -53,7 +40,7 @@ const RESULT_META = {
},
};
export default function BattleDialog({ games }) {
export const BattleDialog = ({ games }) => {
const [open, setOpen] = useState(false);
const [game, setGame] = useState(null);
const [copied, setCopied] = useState(false);
@@ -73,7 +60,7 @@ export default function BattleDialog({ games }) {
}, [games]);
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;
@@ -86,18 +73,6 @@ export default function BattleDialog({ games }) {
const canContinue = !resign && 26 > maxPoints;
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 pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
@@ -113,7 +88,7 @@ export default function BattleDialog({ games }) {
return (
<ThemeProvider theme={darkTheme}>
<Dialog open={open} onClose={() => setOpen(false)} sx={DIALOG_SX}>
<StyledDialog open={open} onClose={() => setOpen(false)}>
<div className="bd">
<div className="bd-header">
<div className="bd-header-left">
@@ -122,7 +97,7 @@ export default function BattleDialog({ games }) {
<i className="fa fa-crosshairs" /> Match Details
</h2>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="bd-header-actions">
{canContinue ? (
<a
className="bd-continue"
@@ -160,33 +135,19 @@ export default function BattleDialog({ games }) {
<span className="bd-vs-score__sep">:</span>
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
</div>
<div className="bd-vs-score" style={{ marginBottom: 8 }}>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#f67d52',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
<div className="bd-vs-score bd-bonus-score">
<span className="bd-bonus-score__red">
<i className="fa fa-star" /> {(game.redBonusPoints ?? 0).toFixed(1)}
</span>
<span className="bd-vs-score__sep">:</span>
<span style={{
font: '700 13px \'Rajdhani\', sans-serif',
color: '#95cff5',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
<span className="bd-bonus-score__blue">
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" />
</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 }}
style={{ '--bd-result-bg': meta.bg, '--bd-result-border': meta.border, '--bd-result-color': meta.color }}
>
<i className={`fa ${meta.icon}`} /> {meta.label}
</div>
@@ -226,7 +187,33 @@ export default function BattleDialog({ games }) {
game={game}
/>
</div>
</Dialog>
</StyledDialog>
</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 { string } from 'prop-types';
/**
* ContactForm Component
@@ -80,4 +81,9 @@ const ContactForm = ({ siteKey, recaptchaFieldId }) => {
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;

View File

@@ -8,6 +8,7 @@
*/
import React, { useState, useCallback } from 'react';
import { shape, string } from 'prop-types';
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -107,4 +108,11 @@ 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 Dialog from '@mui/material/Dialog';
import { styled } from '@mui/material/styles';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { arrayOf, shape, string, bool } from 'prop-types';
const DIALOG_SX = {
const StyledDialog = styled(Dialog)({
'& .MuiDialog-paper': {
background: '#0a0e14',
color: '#e0e0e0',
@@ -47,7 +49,7 @@ const DIALOG_SX = {
background: 'rgba(2, 4, 8, 0.88)',
backdropFilter: 'blur(4px)',
},
};
});
const base64ToArrayBuffer = base64 => {
const binary = atob(base64.replace(/([-_])/g, m => ('-' === m ? '+' : '/')));
@@ -314,7 +316,7 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
)}
</div>
<Dialog open={addModalOpen} onClose={closeAddModal} sx={DIALOG_SX}>
<StyledDialog open={addModalOpen} onClose={closeAddModal}>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogContent>
<TextField
@@ -344,9 +346,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Continue
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
<Dialog open={renameModalOpen} onClose={closeRenameModal} sx={DIALOG_SX}>
<StyledDialog open={renameModalOpen} onClose={closeRenameModal}>
<DialogTitle>Rename Passkey</DialogTitle>
<DialogContent>
<TextField
@@ -374,9 +376,9 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Rename
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
<Dialog open={deleteModalOpen} onClose={closeDeleteModal} sx={DIALOG_SX}>
<StyledDialog open={deleteModalOpen} onClose={closeDeleteModal}>
<DialogTitle>Delete Passkey</DialogTitle>
<DialogContent>
<p>
@@ -402,9 +404,25 @@ const PasskeyManager = ({ credentials, apiRoutes }) => {
Delete
</Button>
</DialogActions>
</Dialog>
</StyledDialog>
</div>
);
};
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 { BarChart } from '@mui/x-charts/BarChart';
import { LineChart } from '@mui/x-charts/LineChart';
import { PieChart } from '@mui/x-charts/PieChart';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { shape, arrayOf, number, string } from 'prop-types';
const darkTheme = createTheme({
palette: {
@@ -136,3 +146,20 @@ export default function ProfileCharts({ chartData }) {
</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.
*/
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 initials = (name || '?').slice(0, 2).toUpperCase();
const initials = useMemo(() => (name || '?').slice(0, 2).toUpperCase(), [name]);
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';
const cssVars = isRed ? {
'--bd-avatar-gradient': 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,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)',
'--bd-avatar-border': 'rgba(173,10,5,0.5)',
'--bd-avatar-color': '#f67d52',
} : {
'--bd-avatar-gradient': '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(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)',
'--bd-avatar-border': 'rgba(35,111,135,0.5)',
'--bd-avatar-color': '#95cff5',
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
<div style={{ position: 'relative' }}>
<div style={{
width: 72, height: 72, borderRadius: '50%',
background: avatarUrl ? 'transparent' : gradient,
border: `2px solid ${border}`,
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 className="bd-avatar-wrap" style={cssVars}>
<div className="bd-avatar-ring-wrap">
<div className="bd-avatar-ring">
{avatarUrl
? <img src={avatarUrl} alt={name} className="bd-avatar-img" />
: initials}
</div>
{0 < bonusPoints && (
<div style={{
position: 'absolute',
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 className="bd-avatar-bonus">
<i className="fa fa-star" />
</div>
)}
</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>
<span className="bd-avatar-name">{name}</span>
<span className="bd-avatar-side">{isRed ? 'Red' : 'Blue'}</span>
</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(
() => 0 < game?.redBonusPoints
|| 0 < game?.blueBonusPoints
@@ -45,95 +55,68 @@ export default function BonusPoints({ game }) {
],
);
if (!hasBonuspoints) {
return '';
}
if (!hasBonuspoints) return '';
return (
<div style={{
padding: '16px 20px 0',
borderTop: '1px solid rgba(255,255,255,0.08)',
marginTop: 16,
marginBottom: 16,
}}
>
<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
<div className="bd-bonus">
<div className="bd-bonus__grid">
<div className="bd-bonus__column bd-bonus__column--red">
<span className="bd-bonus__heading">
<i className="fa fa-star" /> Red Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.redBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />}
{0 < game.redBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />}
{0 < game.redBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />}
{0 < 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} />}
{hasRedNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
<div className="bd-bonus__rows">
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.redBonusStats?.blindHits && (
<StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />
)}
{0 < game.redBonusStats?.chainBest && (
<StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />
)}
{0 < game.redBonusStats?.edgeMines && (
<StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />
)}
{0 < 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} />
)}
{hasRedNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div>
</div>
<div style={{
padding: 16,
border: '1px solid rgba(149,207,245,0.2)',
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
<div className="bd-bonus__column bd-bonus__column--blue">
<span className="bd-bonus__heading">
<i className="fa fa-star" /> Blue Bonus Statistics
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<StatRow
icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)}
valueColor="#ffd700"
/>
{0 < game.blueBonusStats?.blindHits
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />}
{0 < game.blueBonusStats?.chainBest
&& <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />}
{0 < game.blueBonusStats?.edgeMines
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />}
{0 < 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} />}
{hasBlueNoBonuses
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
<div className="bd-bonus__rows">
<StatRow icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)} valueColor="#ffd700" />
{0 < game.blueBonusStats?.blindHits && (
<StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />
)}
{0 < game.blueBonusStats?.chainBest && (
<StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />
)}
{0 < game.blueBonusStats?.edgeMines && (
<StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />
)}
{0 < 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} />
)}
{hasBlueNoBonuses && (
<StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />
)}
</div>
</div>
</div>
</div>
);
}
};
BonusPoints.propTypes = {
game: object.isRequired,
};

View File

@@ -8,33 +8,24 @@
*/
import React from 'react';
import { node, string } from 'prop-types';
export default 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)',
}}
export const StatRow = ({ icon, label, value, valueColor }) => (
<div className="bd-stat-row">
<i className={`fa ${icon} bd-stat-row__icon`} />
<span className="bd-stat-row__label">{label}</span>
<span
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 }} />
<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>
);
}
{value}
</span>
</div>
);
StatRow.propTypes = {
icon: string.isRequired,
label: string.isRequired,
value: node.isRequired,
valueColor: string,
};

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';