ai generated solutions to our ai generated problems

This commit is contained in:
2026-06-22 22:05:46 +01:00
parent 604660e463
commit dd259081c1
2 changed files with 864 additions and 219 deletions
+41 -1
View File
@@ -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 {
+612 -7
View File
@@ -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)
@@ -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 &amp; 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&apos; TSS Bot Toothless&apos; 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">
@@ -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)}