ai generated solutions to our ai generated problems
This commit is contained in:
+41
-1
@@ -42,11 +42,51 @@
|
|||||||
document.documentElement.dataset.theme = theme
|
document.documentElement.dataset.theme = theme
|
||||||
document.documentElement.style.backgroundColor = bg
|
document.documentElement.style.backgroundColor = bg
|
||||||
document.documentElement.style.colorScheme = theme
|
document.documentElement.style.colorScheme = theme
|
||||||
|
|
||||||
|
const customColor = cookies['tssbot_custom_color'] || localStorage.getItem('tssbot.customColor')
|
||||||
|
if (customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)) {
|
||||||
|
document.documentElement.style.setProperty('--color-fury-cyan', customColor)
|
||||||
|
document.documentElement.style.setProperty('--color-ring', customColor)
|
||||||
|
|
||||||
|
const bigint = parseInt(customColor.replace('#', ''), 16)
|
||||||
|
const r = (bigint >> 16) & 255
|
||||||
|
const g = (bigint >> 8) & 255
|
||||||
|
const b = bigint & 255
|
||||||
|
document.documentElement.style.setProperty('--color-shadow', 'rgba(' + r + ', ' + g + ', ' + b + ', ' + (theme === 'dark' ? '0.18' : '0.12') + ')')
|
||||||
|
|
||||||
|
const adjust = (val, percent) => percent > 0
|
||||||
|
? Math.max(0, Math.min(255, Math.round(val + (255 - val) * percent)))
|
||||||
|
: Math.max(0, Math.min(255, Math.round(val * (1 + percent))))
|
||||||
|
const formatHex = (nr, ng, nb) => '#' + ((1 << 24) + (nr << 16) + (ng << 8) + nb).toString(16).slice(1)
|
||||||
|
|
||||||
|
const aquaColor = formatHex(adjust(r, 0.2), adjust(g, 0.2), adjust(b, 0.2))
|
||||||
|
const violetColor = formatHex(adjust(r, -0.2), adjust(g, -0.2), adjust(b, -0.2))
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--color-fury-aqua', aquaColor)
|
||||||
|
document.documentElement.style.setProperty('--color-fury-violet', violetColor)
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute(
|
document.querySelector('meta[name="theme-color"]')?.setAttribute(
|
||||||
'content',
|
'content',
|
||||||
theme === 'dark' ? '#101211' : '#e82517',
|
customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)
|
||||||
|
? customColor
|
||||||
|
: (theme === 'dark' ? '#101211' : '#e82517')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawColors = cookies['tssbot_custom_colors'] || localStorage.getItem('tssbot.customColors')
|
||||||
|
if (rawColors) {
|
||||||
|
const colors = JSON.parse(rawColors)
|
||||||
|
const cssVarMap = { bg: '--color-bg', surface: '--color-surface', text: '--color-text', border: '--color-border', textSoft: '--color-text-soft', textMuted: '--color-text-muted' }
|
||||||
|
for (const [field, cssVar] of Object.entries(cssVarMap)) {
|
||||||
|
if (colors[field] && /^#[0-9a-fA-F]{6}$/.test(colors[field])) {
|
||||||
|
document.documentElement.style.setProperty(cssVar, colors[field])
|
||||||
|
if (field === 'bg') document.documentElement.style.backgroundColor = colors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
let analyticsPreferences = null
|
let analyticsPreferences = null
|
||||||
const serializedPreferences = cookies['tssbot_analytics_preferences']
|
const serializedPreferences = cookies['tssbot_analytics_preferences']
|
||||||
try {
|
try {
|
||||||
|
|||||||
+620
-15
@@ -45,6 +45,7 @@ const primaryNavItems = [
|
|||||||
const utilityNavItems = [
|
const utilityNavItems = [
|
||||||
{ path: '/viewers', label: 'Viewers' },
|
{ path: '/viewers', label: 'Viewers' },
|
||||||
{ path: '/docs', label: 'Setup' },
|
{ path: '/docs', label: 'Setup' },
|
||||||
|
{ path: '/settings', label: 'Settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
||||||
@@ -198,6 +199,7 @@ function parseRoute(pathname = window.location.pathname) {
|
|||||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||||
if (pathname === '/docs') return { page: 'docs', teamName: '' }
|
if (pathname === '/docs') return { page: 'docs', teamName: '' }
|
||||||
|
if (pathname === '/settings') return { page: 'settings', teamName: '' }
|
||||||
if (pathname === '/tournaments') return { page: 'tournaments-list', teamName: '' }
|
if (pathname === '/tournaments') return { page: 'tournaments-list', teamName: '' }
|
||||||
if (pathname.startsWith('/tournaments/')) {
|
if (pathname.startsWith('/tournaments/')) {
|
||||||
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
|
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
|
||||||
@@ -578,6 +580,110 @@ function persistThemePreference(theme) {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customColorCookie = 'tssbot_custom_color'
|
||||||
|
const customColorKey = 'tssbot.customColor'
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
try {
|
||||||
|
const cleanHex = String(hex || '').trim().replace('#', '')
|
||||||
|
const bigint = parseInt(cleanHex, 16)
|
||||||
|
const r = (bigint >> 16) & 255
|
||||||
|
const g = (bigint >> 8) & 255
|
||||||
|
const b = bigint & 255
|
||||||
|
return { r, g, b }
|
||||||
|
} catch {
|
||||||
|
return { r: 232, g: 37, b: 23 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustColorBrightness(hex, percent) {
|
||||||
|
try {
|
||||||
|
const { r, g, b } = hexToRgb(hex)
|
||||||
|
const adjust = (val) => percent > 0
|
||||||
|
? Math.max(0, Math.min(255, Math.round(val + (255 - val) * percent)))
|
||||||
|
: Math.max(0, Math.min(255, Math.round(val * (1 + percent))))
|
||||||
|
const nr = adjust(r)
|
||||||
|
const ng = adjust(g)
|
||||||
|
const nb = adjust(b)
|
||||||
|
return '#' + ((1 << 24) + (nr << 16) + (ng << 8) + nb).toString(16).slice(1)
|
||||||
|
} catch {
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedCustomColorPreference() {
|
||||||
|
const cookieValue = readCookie(customColorCookie)
|
||||||
|
if (cookieValue && /^#[0-9a-fA-F]{6}$/.test(cookieValue)) return cookieValue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const local = window.localStorage.getItem(customColorKey)
|
||||||
|
if (local && /^#[0-9a-fA-F]{6}$/.test(local)) return local
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCustomColorPreference(color) {
|
||||||
|
if (color && /^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||||
|
writeCookie(customColorCookie, color)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(customColorKey, color)
|
||||||
|
} catch { }
|
||||||
|
return color
|
||||||
|
} else {
|
||||||
|
writeCookie(customColorCookie, '')
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(customColorKey)
|
||||||
|
} catch { }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customColorsCookie = 'tssbot_custom_colors'
|
||||||
|
const customColorsKey = 'tssbot.customColors'
|
||||||
|
|
||||||
|
const customColorsFields = ['bg', 'surface', 'text', 'border', 'textSoft', 'textMuted']
|
||||||
|
|
||||||
|
function storedCustomColors() {
|
||||||
|
const parse = (raw) => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw)
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
const result = {}
|
||||||
|
for (const field of customColorsFields) {
|
||||||
|
if (obj[field] && /^#[0-9a-fA-F]{6}$/.test(obj[field])) result[field] = obj[field]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieValue = readCookie(customColorsCookie)
|
||||||
|
if (cookieValue) {
|
||||||
|
const parsed = parse(cookieValue)
|
||||||
|
if (Object.keys(parsed).length) return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const local = window.localStorage.getItem(customColorsKey)
|
||||||
|
if (local) return parse(local)
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCustomColors(colors) {
|
||||||
|
const filtered = {}
|
||||||
|
for (const field of customColorsFields) {
|
||||||
|
if (colors[field] && /^#[0-9a-fA-F]{6}$/.test(colors[field])) filtered[field] = colors[field]
|
||||||
|
}
|
||||||
|
const json = JSON.stringify(filtered)
|
||||||
|
writeCookie(customColorsCookie, json)
|
||||||
|
try { window.localStorage.setItem(customColorsKey, json) } catch { }
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
function storedAnalyticsPreferences() {
|
function storedAnalyticsPreferences() {
|
||||||
const bootPreferences = window.__TSS_BOOT_PREFERENCES__?.analyticsPreferences
|
const bootPreferences = window.__TSS_BOOT_PREFERENCES__?.analyticsPreferences
|
||||||
if (bootPreferences) return normalizeAnalyticsPreferences(bootPreferences)
|
if (bootPreferences) return normalizeAnalyticsPreferences(bootPreferences)
|
||||||
@@ -717,6 +823,7 @@ function routeLabel(route) {
|
|||||||
if (route.page === 'viewers') return 'viewers'
|
if (route.page === 'viewers') return 'viewers'
|
||||||
if (route.page === 'privacy') return 'Privacy notice'
|
if (route.page === 'privacy') return 'Privacy notice'
|
||||||
if (route.page === 'docs') return 'Docs'
|
if (route.page === 'docs') return 'Docs'
|
||||||
|
if (route.page === 'settings') return 'Settings'
|
||||||
if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player'
|
if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player'
|
||||||
return 'Home'
|
return 'Home'
|
||||||
}
|
}
|
||||||
@@ -739,6 +846,7 @@ function canonicalPathForRoute(route) {
|
|||||||
if (route.page === 'viewers') return '/viewers'
|
if (route.page === 'viewers') return '/viewers'
|
||||||
if (route.page === 'privacy') return '/privacy'
|
if (route.page === 'privacy') return '/privacy'
|
||||||
if (route.page === 'docs') return '/docs'
|
if (route.page === 'docs') return '/docs'
|
||||||
|
if (route.page === 'settings') return '/settings'
|
||||||
if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}`
|
if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}`
|
||||||
return '/'
|
return '/'
|
||||||
}
|
}
|
||||||
@@ -855,6 +963,12 @@ function seoForRoute(route, profileDetail = null) {
|
|||||||
robots: 'noindex, follow',
|
robots: 'noindex, follow',
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
title: "Settings | Toothless' TSS Bot",
|
||||||
|
description: 'Customize layout and appearance preferences for Toothless TSS Bot, including custom theme accent colors.',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
path: '/settings',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return byPage[route.page] || {
|
return byPage[route.page] || {
|
||||||
@@ -1263,6 +1377,8 @@ function AppContent() {
|
|||||||
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
||||||
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
|
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
|
||||||
const [theme, setTheme] = useState(() => storedThemePreference())
|
const [theme, setTheme] = useState(() => storedThemePreference())
|
||||||
|
const [customColor, setCustomColor] = useState(() => storedCustomColorPreference())
|
||||||
|
const [customColors, setCustomColors] = useState(() => storedCustomColors())
|
||||||
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
|
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
|
||||||
const [themeTogglePositions, setThemeTogglePositions] = useState(() => {
|
const [themeTogglePositions, setThemeTogglePositions] = useState(() => {
|
||||||
const position = defaultThemeTogglePosition()
|
const position = defaultThemeTogglePosition()
|
||||||
@@ -1302,11 +1418,52 @@ function AppContent() {
|
|||||||
themeRef.current = theme
|
themeRef.current = theme
|
||||||
document.documentElement.dataset.theme = theme
|
document.documentElement.dataset.theme = theme
|
||||||
document.documentElement.style.colorScheme = theme
|
document.documentElement.style.colorScheme = theme
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute(
|
|
||||||
|
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
|
||||||
|
|
||||||
|
if (customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)) {
|
||||||
|
document.documentElement.style.setProperty('--color-fury-cyan', customColor)
|
||||||
|
document.documentElement.style.setProperty('--color-ring', customColor)
|
||||||
|
|
||||||
|
const { r, g, b } = hexToRgb(customColor)
|
||||||
|
document.documentElement.style.setProperty('--color-shadow', `rgba(${r}, ${g}, ${b}, ${theme === 'dark' ? 0.18 : 0.12})`)
|
||||||
|
|
||||||
|
const aquaColor = adjustColorBrightness(customColor, 0.2)
|
||||||
|
const violetColor = adjustColorBrightness(customColor, -0.2)
|
||||||
|
document.documentElement.style.setProperty('--color-fury-aqua', aquaColor)
|
||||||
|
document.documentElement.style.setProperty('--color-fury-violet', violetColor)
|
||||||
|
|
||||||
|
metaThemeColor?.setAttribute('content', customColor)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.removeProperty('--color-fury-cyan')
|
||||||
|
document.documentElement.style.removeProperty('--color-ring')
|
||||||
|
document.documentElement.style.removeProperty('--color-shadow')
|
||||||
|
document.documentElement.style.removeProperty('--color-fury-aqua')
|
||||||
|
document.documentElement.style.removeProperty('--color-fury-violet')
|
||||||
|
|
||||||
|
metaThemeColor?.setAttribute(
|
||||||
'content',
|
'content',
|
||||||
theme === 'dark' ? '#101211' : '#e82517',
|
theme === 'dark' ? '#101211' : '#e82517',
|
||||||
)
|
)
|
||||||
}, [theme])
|
}
|
||||||
|
|
||||||
|
const cssVarMap = {
|
||||||
|
bg: '--color-bg',
|
||||||
|
surface: '--color-surface',
|
||||||
|
text: '--color-text',
|
||||||
|
border: '--color-border',
|
||||||
|
textSoft: '--color-text-soft',
|
||||||
|
textMuted: '--color-text-muted',
|
||||||
|
}
|
||||||
|
for (const [field, cssVar] of Object.entries(cssVarMap)) {
|
||||||
|
const val = customColors[field]
|
||||||
|
if (val && /^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||||
|
document.documentElement.style.setProperty(cssVar, val)
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.removeProperty(cssVar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [theme, customColor, customColors])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
window.clearTimeout(themeTransitionTimerRef.current)
|
window.clearTimeout(themeTransitionTimerRef.current)
|
||||||
@@ -1442,7 +1599,7 @@ function AppContent() {
|
|||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
sendViewerEvent('page_view')
|
sendViewerEvent('page_view')
|
||||||
@@ -1908,7 +2065,7 @@ function AppContent() {
|
|||||||
session_id: analyticsSessionId,
|
session_id: analyticsSessionId,
|
||||||
}),
|
}),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1925,6 +2082,25 @@ function AppContent() {
|
|||||||
setTheme(persistThemePreference(nextTheme))
|
setTheme(persistThemePreference(nextTheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseCustomColor(nextColor) {
|
||||||
|
if (!nextColor) {
|
||||||
|
setCustomColor('')
|
||||||
|
persistCustomColorPreference('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomColor(nextColor)
|
||||||
|
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(nextColor)) {
|
||||||
|
persistCustomColorPreference(nextColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseCustomColors(nextColors) {
|
||||||
|
const saved = persistCustomColors(nextColors)
|
||||||
|
setCustomColors(saved)
|
||||||
|
}
|
||||||
|
|
||||||
const activeNavPath =
|
const activeNavPath =
|
||||||
route.page === 'team'
|
route.page === 'team'
|
||||||
? '/teams'
|
? '/teams'
|
||||||
@@ -1938,6 +2114,8 @@ function AppContent() {
|
|||||||
? '/blog'
|
? '/blog'
|
||||||
: route.page === 'viewers'
|
: route.page === 'viewers'
|
||||||
? '/viewers'
|
? '/viewers'
|
||||||
|
: route.page === 'settings'
|
||||||
|
? '/settings'
|
||||||
: window.location.pathname
|
: window.location.pathname
|
||||||
const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav
|
const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav
|
||||||
|
|
||||||
@@ -2092,6 +2270,18 @@ function AppContent() {
|
|||||||
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||||||
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
|
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
|
||||||
{route.page === 'tournament' ? <TournamentDetailPage tournamentId={route.tournamentId} navigate={navigate} /> : null}
|
{route.page === 'tournament' ? <TournamentDetailPage tournamentId={route.tournamentId} navigate={navigate} /> : null}
|
||||||
|
{route.page === 'settings' ? (
|
||||||
|
<SettingsPage
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={chooseTheme}
|
||||||
|
customColor={customColor}
|
||||||
|
onCustomColorChange={chooseCustomColor}
|
||||||
|
customColors={customColors}
|
||||||
|
onCustomColorsChange={chooseCustomColors}
|
||||||
|
analyticsPreferences={analyticsPreferences}
|
||||||
|
onAnalyticsChoose={chooseAnalyticsConsent}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
<Footer navigate={navigate} />
|
<Footer navigate={navigate} />
|
||||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||||
@@ -2126,6 +2316,13 @@ function Footer({ navigate }) {
|
|||||||
>
|
>
|
||||||
Privacy
|
Privacy
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||||
onClick={() => navigate('/docs')}
|
onClick={() => navigate('/docs')}
|
||||||
@@ -2375,8 +2572,7 @@ function ConsentBanner({ preferences, onChoose }) {
|
|||||||
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
|
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
|
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${disabled ? 'opacity-65' : 'cursor-pointer'
|
||||||
disabled ? 'opacity-65' : 'cursor-pointer'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -2956,12 +3152,422 @@ function BlogPostPage({ navigate, post }) {
|
|||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-4xl font-bold leading-tight">{post.title}</h1>
|
<h1 className="mt-2 text-4xl font-bold leading-tight">{post.title}</h1>
|
||||||
{post.author ? <p className="mt-3 text-text-soft">By {post.author}</p> : null}
|
{post.author ? <p className="mt-3 text-text-soft">By {post.author}</p> : null}
|
||||||
|
{post.excerpt ? (
|
||||||
|
<p className="mt-4 max-w-2xl rounded-md border-l-2 border-fury-cyan bg-surface pl-4 pr-3 py-3 text-base italic leading-7 text-text-soft">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
<MarkdownContent markdown={post.content} />
|
<MarkdownContent markdown={post.content} />
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accentPresets = [
|
||||||
|
{ id: 'default-light', label: 'Classic Light', theme: 'light', color: '' },
|
||||||
|
{ id: 'default-dark', label: 'Classic Dark', theme: 'dark', color: '' },
|
||||||
|
{ id: 'preset-sky', label: 'Sky Blue', theme: null, color: '#0ea5e9' },
|
||||||
|
{ id: 'preset-violet', label: 'Violet', theme: null, color: '#8b5cf6' },
|
||||||
|
{ id: 'preset-emerald', label: 'Emerald', theme: null, color: '#10b981' },
|
||||||
|
{ id: 'preset-rose', label: 'Rose', theme: null, color: '#f43f5e' },
|
||||||
|
{ id: 'preset-amber', label: 'Amber', theme: null, color: '#f59e0b' },
|
||||||
|
{ id: 'preset-indigo', label: 'Indigo', theme: null, color: '#6366f1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const advancedColorDefs = [
|
||||||
|
{
|
||||||
|
field: 'bg',
|
||||||
|
label: 'Background',
|
||||||
|
desc: 'Page background colour',
|
||||||
|
defaults: { light: '#fefde7', dark: '#130d08' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'surface',
|
||||||
|
label: 'Card / Surface',
|
||||||
|
desc: 'Cards, panels, and raised elements',
|
||||||
|
defaults: { light: '#fcfbcf', dark: '#24170d' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'text',
|
||||||
|
label: 'Primary text',
|
||||||
|
desc: 'Headings and body text',
|
||||||
|
defaults: { light: '#000000', dark: '#fdb068' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'textSoft',
|
||||||
|
label: 'Soft text',
|
||||||
|
desc: 'Secondary labels and descriptions',
|
||||||
|
defaults: { light: '#555555', dark: '#fff2e6' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'textMuted',
|
||||||
|
label: 'Muted text',
|
||||||
|
desc: 'Placeholder and hint text',
|
||||||
|
defaults: { light: '#888888', dark: '#fee5cd' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'border',
|
||||||
|
label: 'Border',
|
||||||
|
desc: 'Lines, dividers, and outlines',
|
||||||
|
defaults: { light: '#fee5cd', dark: '#68401f' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function SettingsPage({ theme, onThemeChange, customColor, onCustomColorChange, customColors, onCustomColorsChange, analyticsPreferences, onAnalyticsChoose }) {
|
||||||
|
const [draftColor, setDraftColor] = useState(customColor || '#e82517')
|
||||||
|
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(analyticsPreferences))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(normalizeAnalyticsPreferences(analyticsPreferences))
|
||||||
|
}, [analyticsPreferences])
|
||||||
|
|
||||||
|
function updateDraft(key, value) {
|
||||||
|
setDraft((current) => {
|
||||||
|
if (key === 'analytics' && !value) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
analytics: false,
|
||||||
|
device: false,
|
||||||
|
display: false,
|
||||||
|
locale: false,
|
||||||
|
referrer: false,
|
||||||
|
diagnostics: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...current, [key]: value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAnalytics() {
|
||||||
|
onAnalyticsChoose(normalizeAnalyticsPreferences({ ...draft, chosen: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePresetId = (() => {
|
||||||
|
if (!customColor) return theme === 'light' ? 'default-light' : 'default-dark'
|
||||||
|
const match = accentPresets.find((p) => p.color && p.color.toLowerCase() === customColor.toLowerCase())
|
||||||
|
return match ? match.id : 'custom'
|
||||||
|
})()
|
||||||
|
|
||||||
|
function handlePreset(preset) {
|
||||||
|
if (preset.theme) onThemeChange(preset.theme)
|
||||||
|
onCustomColorChange(preset.color)
|
||||||
|
if (preset.color) setDraftColor(preset.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorInput(value) {
|
||||||
|
setDraftColor(value)
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
|
||||||
|
onCustomColorChange(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorPickerChange(value) {
|
||||||
|
setDraftColor(value)
|
||||||
|
onCustomColorChange(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefault() {
|
||||||
|
onCustomColorChange('')
|
||||||
|
setDraftColor('#e82517')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl pb-16 pt-24 sm:pt-28">
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Preferences</p>
|
||||||
|
<h1 className="mt-2 text-4xl font-bold">Settings</h1>
|
||||||
|
<p className="mt-3 max-w-xl text-text-soft">
|
||||||
|
Customise the look and feel of the site. Your choices are saved in cookies and local storage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
<div className="rounded-xl border border-border bg-fury-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold">Appearance</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">Choose a base theme and accent colour.</p>
|
||||||
|
|
||||||
|
{/* Theme presets */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-text-muted">Theme presets</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{accentPresets.map((preset) => {
|
||||||
|
const isActive = activePresetId === preset.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreset(preset)}
|
||||||
|
className={`flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-semibold transition ${isActive
|
||||||
|
? 'border-fury-cyan bg-fury-cyan text-bg'
|
||||||
|
: 'border-border bg-surface text-text hover:border-ring hover:bg-surface-alt'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.color ? (
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full border border-white/20 shrink-0"
|
||||||
|
style={{ background: preset.color }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full shrink-0"
|
||||||
|
style={{
|
||||||
|
background: preset.id === 'default-light'
|
||||||
|
? 'linear-gradient(135deg, #fefde7 50%, #e82517 50%)'
|
||||||
|
: 'linear-gradient(135deg, #130d08 50%, #ff6a5f 50%)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light / Dark toggle */}
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
<p className="text-sm font-semibold text-text-soft">Base mode:</p>
|
||||||
|
<div className="flex rounded-full border border-border bg-surface p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onThemeChange('light')}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition ${theme === 'light' ? 'bg-text text-bg' : 'text-text-soft hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
☼ Light
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onThemeChange('dark')}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition ${theme === 'dark' ? 'bg-text text-bg' : 'text-text-soft hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
☾ Dark
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom colour picker */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-text-muted">Custom accent colour</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Colour wheel trigger */}
|
||||||
|
<label
|
||||||
|
className="relative h-11 w-11 cursor-pointer overflow-hidden rounded-full border-2 border-border shadow-md transition hover:border-ring hover:scale-105 active:scale-95"
|
||||||
|
style={{ background: draftColor }}
|
||||||
|
title="Pick a custom colour"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={draftColor}
|
||||||
|
onChange={(e) => handleColorPickerChange(e.target.value)}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Hex input */}
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-text-muted">#</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftColor.replace('#', '')}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(e) => handleColorInput('#' + e.target.value.replace(/[^0-9a-fA-F]/g, ''))}
|
||||||
|
className="w-20 bg-transparent text-sm font-mono font-semibold text-text outline-none"
|
||||||
|
placeholder="e82517"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live preview swatch */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-8 rounded-md px-3 py-1.5 text-xs font-semibold text-white shadow"
|
||||||
|
style={{ background: draftColor }}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset button */}
|
||||||
|
{customColor ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetToDefault}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-xs font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
|
Changes all accent highlights, links, and interactive elements across the site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced colours */}
|
||||||
|
<div className="rounded-xl border border-border bg-fury-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold">Advanced colours</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">Override individual colours for the current theme. Each resets independently.</p>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{advancedColorDefs.map(({ field, label, desc, defaults }) => {
|
||||||
|
const defaultVal = defaults[theme] ?? defaults.dark
|
||||||
|
const currentVal = customColors[field] || ''
|
||||||
|
const displayVal = currentVal || defaultVal
|
||||||
|
return (
|
||||||
|
<div key={field} className="flex flex-wrap items-center gap-3">
|
||||||
|
<label
|
||||||
|
className="relative h-9 w-9 shrink-0 cursor-pointer overflow-hidden rounded-full border-2 border-border shadow-sm transition hover:border-ring hover:scale-105 active:scale-95"
|
||||||
|
style={{ background: displayVal }}
|
||||||
|
title={`Pick ${label}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={displayVal}
|
||||||
|
onChange={(e) => onCustomColorsChange({ ...customColors, [field]: e.target.value })}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-sm font-semibold text-text leading-none">{label}</span>
|
||||||
|
<span className="text-xs text-text-muted mt-0.5">{desc}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 rounded-md border border-border bg-surface px-2 py-1.5">
|
||||||
|
<span className="text-xs font-semibold text-text-muted">#</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayVal.replace('#', '')}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = '#' + e.target.value.replace(/[^0-9a-fA-F]/g, '')
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(v)) onCustomColorsChange({ ...customColors, [field]: v })
|
||||||
|
}}
|
||||||
|
className="w-16 bg-transparent text-xs font-mono font-semibold text-text outline-none"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{currentVal ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...customColors }
|
||||||
|
delete next[field]
|
||||||
|
onCustomColorsChange(next)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-border px-2 py-1.5 text-xs font-semibold text-text-muted transition hover:bg-surface hover:text-text"
|
||||||
|
title="Reset to theme default"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1.5 text-xs text-text-muted">Theme default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(customColors).length > 0 ? (
|
||||||
|
<div className="mt-5 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCustomColorsChange({})}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-xs font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||||
|
>
|
||||||
|
Reset all to theme defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cookie & Analytics */}
|
||||||
|
<div className="rounded-xl border border-border bg-fury-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold">Cookie & analytics settings</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-text-soft">
|
||||||
|
We use a necessary cookie to remember these choices. You can also allow analytics for the public viewers page and choose which details are included.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<PreferenceToggle
|
||||||
|
checked
|
||||||
|
description="Stores your theme and consent choice so preferences persist across visits."
|
||||||
|
disabled
|
||||||
|
label="Necessary cookies"
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.analytics}
|
||||||
|
description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
|
||||||
|
label="Viewer analytics"
|
||||||
|
onChange={(value) => updateDraft('analytics', value)}
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.device}
|
||||||
|
description="Includes browser, operating system, and broad device type."
|
||||||
|
disabled={!draft.analytics}
|
||||||
|
label="Browser and device details"
|
||||||
|
onChange={(value) => updateDraft('device', value)}
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.display}
|
||||||
|
description="Includes screen size, viewport size, and colour depth."
|
||||||
|
disabled={!draft.analytics}
|
||||||
|
label="Screen details"
|
||||||
|
onChange={(value) => updateDraft('display', value)}
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.locale}
|
||||||
|
description="Includes browser language and timezone."
|
||||||
|
disabled={!draft.analytics}
|
||||||
|
label="Language and timezone"
|
||||||
|
onChange={(value) => updateDraft('locale', value)}
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.referrer}
|
||||||
|
description="Includes the page that linked you here when the browser provides it."
|
||||||
|
disabled={!draft.analytics}
|
||||||
|
label="Referrer"
|
||||||
|
onChange={(value) => updateDraft('referrer', value)}
|
||||||
|
/>
|
||||||
|
<PreferenceToggle
|
||||||
|
checked={draft.diagnostics}
|
||||||
|
description="Includes network quality, privacy signals, touch support, CPU/memory hints, and other browser diagnostics."
|
||||||
|
disabled={!draft.analytics}
|
||||||
|
label="Technical diagnostics"
|
||||||
|
onChange={(value) => updateDraft('diagnostics', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAnalyticsChoose({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||||
|
>
|
||||||
|
Decline all analytics
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveAnalytics}
|
||||||
|
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
||||||
|
>
|
||||||
|
Save preferences
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function PrivacySection({ children, title }) {
|
function PrivacySection({ children, title }) {
|
||||||
return (
|
return (
|
||||||
<article className="border-l border-border pl-4">
|
<article className="border-l border-border pl-4">
|
||||||
@@ -3000,7 +3606,7 @@ function Landing({
|
|||||||
Toothless' TSS Bot
|
Toothless' TSS Bot
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-2xl text-xl leading-9 text-text-soft">
|
<p className="mt-6 max-w-2xl text-xl leading-9 text-text-soft">
|
||||||
YOUR TSS web companion, free forever ♥
|
A TSS web companion, free forever ♥
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 w-full max-w-xl">
|
<div className="mt-8 w-full max-w-xl">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -3749,11 +4355,11 @@ function deadVehicleKeysFromEventLog(eventLog) {
|
|||||||
|
|
||||||
function logLookups(participants) {
|
function logLookups(participants) {
|
||||||
const players = new Map()
|
const players = new Map()
|
||||||
;(participants || []).forEach((participant) => {
|
; (participants || []).forEach((participant) => {
|
||||||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||||||
;(participant.players || []).forEach((player) => {
|
; (participant.players || []).forEach((player) => {
|
||||||
const vehicles = new Map()
|
const vehicles = new Map()
|
||||||
;(player.vehicles || []).forEach((vehicle) => {
|
; (player.vehicles || []).forEach((vehicle) => {
|
||||||
vehicles.set(String(vehicle.cdk || ''), vehicle.name || vehicle.cdk || 'Unknown')
|
vehicles.set(String(vehicle.cdk || ''), vehicle.name || vehicle.cdk || 'Unknown')
|
||||||
})
|
})
|
||||||
players.set(String(player.uid), {
|
players.set(String(player.uid), {
|
||||||
@@ -3770,9 +4376,9 @@ function logLookups(participants) {
|
|||||||
|
|
||||||
function logNameLookups(participants) {
|
function logNameLookups(participants) {
|
||||||
const players = new Map()
|
const players = new Map()
|
||||||
;(participants || []).forEach((participant) => {
|
; (participants || []).forEach((participant) => {
|
||||||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||||||
;(participant.players || []).forEach((player) => {
|
; (participant.players || []).forEach((player) => {
|
||||||
const name = String(player.nick || '').trim()
|
const name = String(player.nick || '').trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
players.set(name.toLowerCase(), {
|
players.set(name.toLowerCase(), {
|
||||||
@@ -4369,7 +4975,7 @@ function TournamentMatchCard({ match, navigate, onHover }) {
|
|||||||
const bWon = winner && teamB && winner === teamB.toLowerCase()
|
const bWon = winner && teamB && winner === teamB.toLowerCase()
|
||||||
const decided = Boolean(winner)
|
const decided = Boolean(winner)
|
||||||
const battles = Array.isArray(match.battles) ? match.battles : []
|
const battles = Array.isArray(match.battles) ? match.battles : []
|
||||||
const fire = onHover || (() => {})
|
const fire = onHover || (() => { })
|
||||||
const lastKey = useRef('')
|
const lastKey = useRef('')
|
||||||
|
|
||||||
// Hover a team name → light just that team's run (green). Hover anywhere else on
|
// Hover a team name → light just that team's run (green). Hover anywhere else on
|
||||||
@@ -5223,8 +5829,7 @@ function ViewersPage({ viewers }) {
|
|||||||
<div className="flex rounded-md border border-border bg-surface p-1 text-sm font-semibold" aria-label="Analytics time window">
|
<div className="flex rounded-md border border-border bg-surface p-1 text-sm font-semibold" aria-label="Analytics time window">
|
||||||
{Object.entries(periods).map(([key, period]) => (
|
{Object.entries(periods).map(([key, period]) => (
|
||||||
<button
|
<button
|
||||||
className={`rounded px-3 py-1.5 transition ${
|
className={`rounded px-3 py-1.5 transition ${analyticsWindow === key ? 'bg-text text-bg apricot-button-text shadow-sm' : 'text-text-soft hover:text-text'
|
||||||
analyticsWindow === key ? 'bg-text text-bg apricot-button-text shadow-sm' : 'text-text-soft hover:text-text'
|
|
||||||
}`}
|
}`}
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setAnalyticsWindow(key)}
|
onClick={() => setAnalyticsWindow(key)}
|
||||||
|
|||||||
Reference in New Issue
Block a user