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.style.backgroundColor = bg
|
||||
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(
|
||||
'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
|
||||
const serializedPreferences = cookies['tssbot_analytics_preferences']
|
||||
try {
|
||||
|
||||
+612
-7
@@ -45,6 +45,7 @@ const primaryNavItems = [
|
||||
const utilityNavItems = [
|
||||
{ path: '/viewers', label: 'Viewers' },
|
||||
{ path: '/docs', label: 'Setup' },
|
||||
{ path: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
||||
@@ -198,6 +199,7 @@ function parseRoute(pathname = window.location.pathname) {
|
||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||
if (pathname === '/privacy') return { page: 'privacy', 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.startsWith('/tournaments/')) {
|
||||
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
|
||||
@@ -578,6 +580,110 @@ function persistThemePreference(theme) {
|
||||
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() {
|
||||
const bootPreferences = window.__TSS_BOOT_PREFERENCES__?.analyticsPreferences
|
||||
if (bootPreferences) return normalizeAnalyticsPreferences(bootPreferences)
|
||||
@@ -717,6 +823,7 @@ function routeLabel(route) {
|
||||
if (route.page === 'viewers') return 'viewers'
|
||||
if (route.page === 'privacy') return 'Privacy notice'
|
||||
if (route.page === 'docs') return 'Docs'
|
||||
if (route.page === 'settings') return 'Settings'
|
||||
if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player'
|
||||
return 'Home'
|
||||
}
|
||||
@@ -739,6 +846,7 @@ function canonicalPathForRoute(route) {
|
||||
if (route.page === 'viewers') return '/viewers'
|
||||
if (route.page === 'privacy') return '/privacy'
|
||||
if (route.page === 'docs') return '/docs'
|
||||
if (route.page === 'settings') return '/settings'
|
||||
if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}`
|
||||
return '/'
|
||||
}
|
||||
@@ -855,6 +963,12 @@ function seoForRoute(route, profileDetail = null) {
|
||||
robots: 'noindex, follow',
|
||||
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] || {
|
||||
@@ -1263,6 +1377,8 @@ function AppContent() {
|
||||
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
||||
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
|
||||
const [theme, setTheme] = useState(() => storedThemePreference())
|
||||
const [customColor, setCustomColor] = useState(() => storedCustomColorPreference())
|
||||
const [customColors, setCustomColors] = useState(() => storedCustomColors())
|
||||
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
|
||||
const [themeTogglePositions, setThemeTogglePositions] = useState(() => {
|
||||
const position = defaultThemeTogglePosition()
|
||||
@@ -1302,11 +1418,52 @@ function AppContent() {
|
||||
themeRef.current = theme
|
||||
document.documentElement.dataset.theme = 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',
|
||||
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(() => () => {
|
||||
window.clearTimeout(themeTransitionTimerRef.current)
|
||||
@@ -1925,6 +2082,25 @@ function AppContent() {
|
||||
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 =
|
||||
route.page === 'team'
|
||||
? '/teams'
|
||||
@@ -1938,6 +2114,8 @@ function AppContent() {
|
||||
? '/blog'
|
||||
: route.page === 'viewers'
|
||||
? '/viewers'
|
||||
: route.page === 'settings'
|
||||
? '/settings'
|
||||
: window.location.pathname
|
||||
const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav
|
||||
|
||||
@@ -2092,6 +2270,18 @@ function AppContent() {
|
||||
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||||
{route.page === 'tournaments-list' ? <TournamentsPage 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>
|
||||
<Footer navigate={navigate} />
|
||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||
@@ -2126,6 +2316,13 @@ function Footer({ navigate }) {
|
||||
>
|
||||
Privacy
|
||||
</button>
|
||||
<button
|
||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||
onClick={() => navigate('/settings')}
|
||||
type="button"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||
onClick={() => navigate('/docs')}
|
||||
@@ -2375,8 +2572,7 @@ function ConsentBanner({ preferences, onChoose }) {
|
||||
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
|
||||
return (
|
||||
<label
|
||||
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
|
||||
disabled ? 'opacity-65' : 'cursor-pointer'
|
||||
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${disabled ? 'opacity-65' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
@@ -2956,12 +3152,422 @@ function BlogPostPage({ navigate, post }) {
|
||||
</p>
|
||||
<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.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>
|
||||
<MarkdownContent markdown={post.content} />
|
||||
</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 }) {
|
||||
return (
|
||||
<article className="border-l border-border pl-4">
|
||||
@@ -3000,7 +3606,7 @@ function Landing({
|
||||
Toothless' TSS Bot
|
||||
</h1>
|
||||
<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>
|
||||
<div className="mt-8 w-full max-w-xl">
|
||||
<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">
|
||||
{Object.entries(periods).map(([key, period]) => (
|
||||
<button
|
||||
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'
|
||||
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'
|
||||
}`}
|
||||
key={key}
|
||||
onClick={() => setAnalyticsWindow(key)}
|
||||
|
||||
Reference in New Issue
Block a user