This commit is contained in:
2026-05-16 12:20:14 +01:00
parent 4feac9a1fc
commit 1cc500e428
3 changed files with 706 additions and 33 deletions
+39 -4
View File
@@ -259,6 +259,7 @@ function ensureAnalyticsDb() {
os text not null default 'Unknown', os text not null default 'Unknown',
device text not null default 'Desktop', device text not null default 'Desktop',
screen text not null default '', screen text not null default '',
theme text not null default 'light',
language text not null default '', language text not null default '',
timezone text not null default '', timezone text not null default '',
country text not null default '', country text not null default '',
@@ -284,6 +285,7 @@ function ensureAnalyticsDb() {
os text not null default 'Unknown', os text not null default 'Unknown',
device text not null default 'Desktop', device text not null default 'Desktop',
screen text not null default '', screen text not null default '',
theme text not null default 'light',
language text not null default '', language text not null default '',
timezone text not null default '', timezone text not null default '',
country text not null default '', country text not null default '',
@@ -314,6 +316,8 @@ function ensureAnalyticsDb() {
`alter table active_viewers add column latitude real`, `alter table active_viewers add column latitude real`,
`alter table viewer_events add column longitude real`, `alter table viewer_events add column longitude real`,
`alter table active_viewers add column longitude real`, `alter table active_viewers add column longitude real`,
`alter table viewer_events add column theme text not null default 'light'`,
`alter table active_viewers add column theme text not null default 'light'`,
]) { ]) {
try { try {
analyticsDb.exec(statement) analyticsDb.exec(statement)
@@ -578,6 +582,10 @@ function numberHeader(req, name, min, max) {
return value return value
} }
function sanitizeTheme(value) {
return value === 'dark' ? 'dark' : 'light'
}
const CITY_COORDINATE_OVERRIDES = new Map([ const CITY_COORDINATE_OVERRIDES = new Map([
['GB|ENGLAND|MILTON KEYNES', { latitude: 52.0406, longitude: -0.7594 }], ['GB|ENGLAND|MILTON KEYNES', { latitude: 52.0406, longitude: -0.7594 }],
]) ])
@@ -969,6 +977,7 @@ function recordViewerEvent(req, payload) {
os: sanitizeText(payload.os || (shareUserAgent ? serverClient.os : 'Not shared'), 80), os: sanitizeText(payload.os || (shareUserAgent ? serverClient.os : 'Not shared'), 80),
device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80), device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80),
screen: sanitizeText(payload.screen, 40), screen: sanitizeText(payload.screen, 40),
theme: sanitizeTheme(payload.theme),
language: sanitizeText(payload.language, 40), language: sanitizeText(payload.language, 40),
timezone: sanitizeText(payload.timezone, 80), timezone: sanitizeText(payload.timezone, 80),
country: location.country, country: location.country,
@@ -989,22 +998,22 @@ function recordViewerEvent(req, payload) {
db.prepare(` db.prepare(`
insert into viewer_events insert into viewer_events
(occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title, (occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
referrer, user_agent, browser, os, device, screen, language, timezone, referrer, user_agent, browser, os, device, screen, theme, language, timezone,
country, region, city, latitude, longitude, consent, metadata) country, region, city, latitude, longitude, consent, metadata)
values values
(@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title, (@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
@country, @region, @city, @latitude, @longitude, @consent, @metadata) @country, @region, @city, @latitude, @longitude, @consent, @metadata)
`).run({ ...event, occurred_at: now }) `).run({ ...event, occurred_at: now })
db.prepare(` db.prepare(`
insert into active_viewers insert into active_viewers
(session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title, (session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title,
referrer, user_agent, browser, os, device, screen, language, timezone, referrer, user_agent, browser, os, device, screen, theme, language, timezone,
country, region, city, latitude, longitude) country, region, city, latitude, longitude)
values values
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title, (@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
@country, @region, @city, @latitude, @longitude) @country, @region, @city, @latitude, @longitude)
on conflict(session_id) do update set on conflict(session_id) do update set
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
@@ -1016,6 +1025,7 @@ function recordViewerEvent(req, payload) {
os = excluded.os, os = excluded.os,
device = excluded.device, device = excluded.device,
screen = excluded.screen, screen = excluded.screen,
theme = excluded.theme,
language = excluded.language, language = excluded.language,
timezone = excluded.timezone, timezone = excluded.timezone,
country = excluded.country, country = excluded.country,
@@ -1077,6 +1087,7 @@ function viewerDashboard() {
max(os) as os, max(os) as os,
max(device) as device, max(device) as device,
max(screen) as screen, max(screen) as screen,
max(theme) as theme,
max(language) as language, max(language) as language,
max(timezone) as timezone, max(timezone) as timezone,
max(country) as country, max(country) as country,
@@ -1102,6 +1113,7 @@ function viewerDashboard() {
os: row.os, os: row.os,
device: row.device, device: row.device,
screen: row.screen, screen: row.screen,
theme: sanitizeTheme(row.theme),
language: row.language, language: row.language,
timezone: row.timezone, timezone: row.timezone,
country: row.country, country: row.country,
@@ -1121,12 +1133,14 @@ function viewerDashboard() {
visitors: new Set(), visitors: new Set(),
clients: new Map(), clients: new Map(),
countries: new Set(), countries: new Set(),
themes: new Map(),
last_seen_at: viewer.last_seen_at, last_seen_at: viewer.last_seen_at,
} }
existing.viewers += viewer.sessions || 1 existing.viewers += viewer.sessions || 1
existing.visitors.add(viewer.visitor) existing.visitors.add(viewer.visitor)
if (viewer.country) existing.countries.add(viewer.country) if (viewer.country) existing.countries.add(viewer.country)
existing.themes.set(viewer.theme, (existing.themes.get(viewer.theme) || 0) + (viewer.sessions || 1))
const clientKey = `${viewer.browser} on ${viewer.os}` const clientKey = `${viewer.browser} on ${viewer.os}`
existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1) existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1)
if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) { if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) {
@@ -1142,6 +1156,9 @@ function viewerDashboard() {
viewers: page.viewers, viewers: page.viewers,
visitors: page.visitors.size, visitors: page.visitors.size,
countries: Array.from(page.countries).sort(), countries: Array.from(page.countries).sort(),
themes: Array.from(page.themes.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([theme, count]) => ({ theme, count })),
clients: Array.from(page.clients.entries()) clients: Array.from(page.clients.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 4) .slice(0, 4)
@@ -1188,6 +1205,22 @@ function viewerDashboard() {
limit 12 limit 12
`).all(thirtyDaysSince) `).all(thirtyDaysSince)
const themes = db.prepare(`
select theme, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= ?
group by theme
order by events desc
`).all(daySince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))
const themes30d = db.prepare(`
select theme, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= ?
group by theme
order by events desc
`).all(thirtyDaysSince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))
const activity24h = db.prepare(` const activity24h = db.prepare(`
select select
substr(occurred_at, 1, 13) || ':00:00.000Z' as date, substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
@@ -1447,6 +1480,8 @@ function viewerDashboard() {
top_pages_30d: topPages30d, top_pages_30d: topPages30d,
clients, clients,
clients_30d: clients30d, clients_30d: clients30d,
themes,
themes_30d: themes30d,
activity_24h: activity24hWithLabels, activity_24h: activity24hWithLabels,
activity_30d: activityWithLocations, activity_30d: activityWithLocations,
countries_24h: countries24h, countries_24h: countries24h,
+310 -26
View File
@@ -37,6 +37,8 @@ const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
const analyticsPreferencesCookie = 'tssbot_analytics_preferences' const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
const analyticsVisitorKey = 'tssbot.analyticsVisitor' const analyticsVisitorKey = 'tssbot.analyticsVisitor'
const analyticsConsentVersion = 3 const analyticsConsentVersion = 3
const themePreferenceKey = 'tssbot.theme'
const themePreferenceCookie = 'tssbot_theme'
const liveRefreshMs = 15000 const liveRefreshMs = 15000
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
@@ -193,6 +195,34 @@ function writeCookie(name, value) {
} }
} }
function normalizeThemePreference(value) {
return value === 'dark' ? 'dark' : 'light'
}
function storedThemePreference() {
const cookieValue = readCookie(themePreferenceCookie)
if (cookieValue) return normalizeThemePreference(cookieValue)
try {
return normalizeThemePreference(window.localStorage.getItem(themePreferenceKey))
} catch {
return 'light'
}
}
function persistThemePreference(theme) {
const normalized = normalizeThemePreference(theme)
writeCookie(themePreferenceCookie, normalized)
try {
window.localStorage.setItem(themePreferenceKey, normalized)
} catch {
// Cookies still remember the theme when local storage is blocked.
}
return normalized
}
function storedAnalyticsPreferences() { function storedAnalyticsPreferences() {
const cookieValue = readCookie(analyticsPreferencesCookie) const cookieValue = readCookie(analyticsPreferencesCookie)
if (cookieValue) { if (cookieValue) {
@@ -374,6 +404,98 @@ function Stat({ label, value }) {
) )
} }
function ThemeToggle({ theme, onThemeChange }) {
const isDark = theme === 'dark'
return (
<button
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-pressed={isDark}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-border bg-surface text-sm font-semibold text-text-soft transition hover:border-ring hover:text-text"
onClick={() => onThemeChange(isDark ? 'light' : 'dark')}
title={isDark ? 'Light mode' : 'Dark mode'}
type="button"
>
<span aria-hidden="true">{isDark ? '☾' : '☼'}</span>
</button>
)
}
function defaultThemeTogglePosition() {
const inset = window.innerWidth >= 640 ? 32 : 20
return {
x: window.innerWidth - inset - 36,
y: inset,
}
}
function themeToggleDockPosition(navPill) {
if (!navPill) return defaultThemeTogglePosition()
const width = navPill.offsetWidth
const height = navPill.offsetHeight
const left = (window.innerWidth - width) / 2
const top = 16
return {
x: Math.round(left + width - 48),
y: Math.round(top + (height - 36) / 2),
}
}
function ThemeToggleMover({ position, theme, onThemeChange }) {
const previousPositionRef = useRef(position)
const [motion, setMotion] = useState(() => ({
from: position,
to: position,
mid: position,
key: 0,
animate: false,
}))
useEffect(() => {
const from = previousPositionRef.current
const to = position
if (from.x === to.x && from.y === to.y) return
const distance = Math.hypot(to.x - from.x, to.y - from.y)
const lift = Math.min(96, Math.max(24, distance * 0.16))
const mid = {
x: Math.round((from.x + to.x) / 2),
y: Math.round(Math.min(from.y, to.y) - lift),
}
previousPositionRef.current = to
setMotion((current) => ({
from,
to,
mid,
key: current.key + 1,
animate: true,
}))
}, [position])
return (
<div
className={`theme-toggle-mover fixed left-0 top-0 z-[60] ${
motion.animate ? 'theme-toggle-mover-arc' : ''
}`}
key={motion.key}
style={{
'--from-x': `${motion.from.x}px`,
'--from-y': `${motion.from.y}px`,
'--mid-x': `${motion.mid.x}px`,
'--mid-y': `${motion.mid.y}px`,
'--to-x': `${motion.to.x}px`,
'--to-y': `${motion.to.y}px`,
transform: `translate3d(${motion.to.x}px, ${motion.to.y}px, 0)`,
}}
>
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
</div>
)
}
function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) { function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) {
const containerRef = useRef(null) const containerRef = useRef(null)
const widgetIdRef = useRef(null) const widgetIdRef = useRef(null)
@@ -545,7 +667,9 @@ function AppContent() {
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
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 [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40) const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
const [themeTogglePosition, setThemeTogglePosition] = useState(() => defaultThemeTogglePosition())
const [teamQuery, setTeamQuery] = useState('') const [teamQuery, setTeamQuery] = useState('')
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' }) const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
const [teamSearchResults, setTeamSearchResults] = useState([]) const [teamSearchResults, setTeamSearchResults] = useState([])
@@ -561,11 +685,29 @@ function AppContent() {
) )
const matches = live.data?.matches || [] const matches = live.data?.matches || []
const liveRef = useRef(live) const liveRef = useRef(live)
const navPillRef = useRef(null)
const themeRef = useRef(theme)
const themeTransitionTimerRef = useRef(null)
useEffect(() => { useEffect(() => {
liveRef.current = live liveRef.current = live
}, [live]) }, [live])
useEffect(() => {
themeRef.current = theme
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
document.querySelector('meta[name="theme-color"]')?.setAttribute(
'content',
theme === 'dark' ? '#101211' : '#e82517',
)
}, [theme])
useEffect(() => () => {
window.clearTimeout(themeTransitionTimerRef.current)
document.documentElement.classList.remove('theme-transition')
}, [])
function navigate(path, { replace = false } = {}) { function navigate(path, { replace = false } = {}) {
if (replace) { if (replace) {
window.history.replaceState({}, '', path) window.history.replaceState({}, '', path)
@@ -647,6 +789,7 @@ function AppContent() {
locale: localeDetails, locale: localeDetails,
referrer: referrerDetails, referrer: referrerDetails,
diagnostics: diagnosticsDetails, diagnostics: diagnosticsDetails,
theme: themeRef.current,
}, },
} }
@@ -695,6 +838,7 @@ function AppContent() {
screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '', screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '',
language: localeDetails ? navigator.language : '', language: localeDetails ? navigator.language : '',
timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '', timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
theme: themeRef.current,
metadata, metadata,
} }
@@ -1150,6 +1294,16 @@ function AppContent() {
setAnalyticsPreferences(nextPreferences) setAnalyticsPreferences(nextPreferences)
} }
function chooseTheme(nextTheme) {
if (nextTheme === themeRef.current) return
window.clearTimeout(themeTransitionTimerRef.current)
document.documentElement.classList.add('theme-transition')
themeTransitionTimerRef.current = window.setTimeout(() => {
document.documentElement.classList.remove('theme-transition')
}, 460)
setTheme(persistThemePreference(nextTheme))
}
const activeNavPath = const activeNavPath =
route.page === 'team' route.page === 'team'
? '/teams' ? '/teams'
@@ -1160,6 +1314,28 @@ function AppContent() {
: window.location.pathname : window.location.pathname
const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav const shouldShowFloatingNav = route.page !== 'home' || showFloatingNav
useEffect(() => {
let frame = 0
function updateThemeTogglePosition() {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(() => {
setThemeTogglePosition(
shouldShowFloatingNav
? themeToggleDockPosition(navPillRef.current)
: defaultThemeTogglePosition(),
)
})
}
updateThemeTogglePosition()
window.addEventListener('resize', updateThemeTogglePosition)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('resize', updateThemeTogglePosition)
}
}, [route.page, shouldShowFloatingNav])
return ( return (
<main className="min-h-screen bg-bg text-text"> <main className="min-h-screen bg-bg text-text">
<header <header
@@ -1168,7 +1344,10 @@ function AppContent() {
: 'pointer-events-none -translate-y-8 opacity-0' : 'pointer-events-none -translate-y-8 opacity-0'
}`} }`}
> >
<div className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full border border-border bg-fury-white/92 px-2 py-2 shadow-[0_12px_36px_rgba(0,0,0,0.16)] backdrop-blur sm:px-3"> <div
className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-full border border-border bg-fury-white/92 py-2 pl-2 pr-13 shadow-[0_12px_36px_rgba(0,0,0,0.16)] backdrop-blur sm:pl-3"
ref={navPillRef}
>
<button <button
className="hidden shrink-0 rounded-full px-3 py-2 text-sm font-bold tracking-tight transition hover:bg-surface hover:text-fury-cyan sm:block" className="hidden shrink-0 rounded-full px-3 py-2 text-sm font-bold tracking-tight transition hover:bg-surface hover:text-fury-cyan sm:block"
onClick={() => navigate('/')} onClick={() => navigate('/')}
@@ -1181,7 +1360,7 @@ function AppContent() {
{navItems.map((item) => ( {navItems.map((item) => (
<button <button
className={`shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition ${activeNavPath === item.path className={`shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition ${activeNavPath === item.path
? 'bg-text text-bg' ? 'bg-text text-bg apricot-button-text'
: 'text-text-soft hover:bg-surface hover:text-text' : 'text-text-soft hover:bg-surface hover:text-text'
}`} }`}
key={item.path} key={item.path}
@@ -1194,6 +1373,11 @@ function AppContent() {
</nav> </nav>
</div> </div>
</header> </header>
<ThemeToggleMover
position={themeTogglePosition}
theme={theme}
onThemeChange={chooseTheme}
/>
<section className="mx-auto w-full max-w-7xl px-5 sm:px-8"> <section className="mx-auto w-full max-w-7xl px-5 sm:px-8">
{route.page === 'home' ? ( {route.page === 'home' ? (
@@ -1514,9 +1698,9 @@ function PrivacyPage() {
<div className="mt-8 grid gap-6 text-sm leading-6 text-text-soft"> <div className="mt-8 grid gap-6 text-sm leading-6 text-text-soft">
<PrivacySection title="What we collect"> <PrivacySection title="What we collect">
Necessary cookies store your cookie and analytics choices. If you opt in to Necessary cookies store your cookie, theme, and analytics choices. If you opt in to
viewer analytics, we collect page views, live viewing status, a pseudonymous viewer analytics, we collect page views, live viewing status, a pseudonymous
visitor ID, and a session ID. Optional settings control whether browser/device, visitor ID, a session ID, and the active light/dark theme. Optional settings control whether browser/device,
screen, language/timezone, referrer, and technical diagnostics are included. screen, language/timezone, referrer, and technical diagnostics are included.
Technical diagnostics can include HTTP version, request protocol details, Technical diagnostics can include HTTP version, request protocol details,
content negotiation headers, browser privacy signals, network quality, touch content negotiation headers, browser privacy signals, network quality, touch
@@ -1524,7 +1708,7 @@ function PrivacyPage() {
</PrivacySection> </PrivacySection>
<PrivacySection title="Why we collect it"> <PrivacySection title="Why we collect it">
Necessary cookies are used to remember your privacy choices. Optional viewer Necessary cookies are used to remember your privacy and theme choices. Optional viewer
analytics are used only to power the public viewers page, understand basic site analytics are used only to power the public viewers page, understand basic site
activity, and keep abuse low. activity, and keep abuse low.
</PrivacySection> </PrivacySection>
@@ -1601,7 +1785,7 @@ function Landing({
<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">
<button <button
className="min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan" className="apricot-button-text min-h-15 rounded-lg bg-text px-5 py-4 text-base font-semibold text-bg transition hover:bg-fury-cyan"
onClick={() => navigate('/teams')} onClick={() => navigate('/teams')}
type="button" type="button"
> >
@@ -1809,7 +1993,7 @@ function LandingTrustSection({ navigate }) {
<div className="flex flex-wrap gap-3 lg:justify-end"> <div className="flex flex-wrap gap-3 lg:justify-end">
<a <a
className="rounded-lg bg-text px-5 py-3 text-sm font-semibold text-bg transition hover:bg-fury-cyan" className="apricot-button-text rounded-lg bg-text px-5 py-3 text-sm font-semibold text-bg transition hover:bg-fury-cyan"
href="https://sre.pawjob.us/" href="https://sre.pawjob.us/"
> >
Visit SREBOT Visit SREBOT
@@ -1894,10 +2078,16 @@ function RecentGamesSection({ live, matches, navigate }) {
) )
} }
let cachedPixelMountainsCanvas = null const cachedPixelMountainsCanvases = new Map()
function renderPixelMountainsCanvas() { function renderPixelMountainsCanvas(theme = 'light') {
if (cachedPixelMountainsCanvas) return cachedPixelMountainsCanvas const isDark = theme === 'dark'
const palette =
isDark
? ['#332614', '#4c341b', '#68431f', '#8a5824']
: ['#fcfbcf', '#fff2e6', '#fee5cd', '#fdca9b']
const cached = cachedPixelMountainsCanvases.get(theme)
if (cached) return cached
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
@@ -1952,7 +2142,7 @@ function renderPixelMountainsCanvas() {
[width * 0.86, height * 0.56], [width * 0.86, height * 0.56],
[width, height * 0.64], [width, height * 0.64],
], ],
'#fcfbcf', palette[0],
1.1, 1.1,
) )
@@ -1968,7 +2158,7 @@ function renderPixelMountainsCanvas() {
[width * 0.78, height * 0.48], [width * 0.78, height * 0.48],
[width, height * 0.5], [width, height * 0.5],
], ],
'#fff2e6', palette[1],
1.6, 1.6,
) )
@@ -1984,7 +2174,7 @@ function renderPixelMountainsCanvas() {
[width * 0.84, height * 0.7], [width * 0.84, height * 0.7],
[width, height * 0.68], [width, height * 0.68],
], ],
'#fee5cd', palette[2],
2.1, 2.1,
) )
@@ -1999,31 +2189,90 @@ function renderPixelMountainsCanvas() {
[width * 0.86, height * 0.95], [width * 0.86, height * 0.95],
[width, height * 0.93], [width, height * 0.93],
], ],
'#fdca9b', palette[3],
1.4, 1.4,
) )
} }
draw() draw()
cachedPixelMountainsCanvas = canvas cachedPixelMountainsCanvases.set(theme, canvas)
return canvas return canvas
} }
function PixelMountains() { function PixelMountains() {
const canvasRef = useRef(null) const [activeTheme, setActiveTheme] = useState(() =>
document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
)
const [previousTheme, setPreviousTheme] = useState(null)
const [skyTransition, setSkyTransition] = useState('')
const activeCanvasRef = useRef(null)
const previousCanvasRef = useRef(null)
const fadeTimerRef = useRef(null)
const skyTimerRef = useRef(null)
useEffect(() => { function drawCanvas(canvas, theme) {
const canvas = canvasRef.current if (!canvas || !theme) return
const source = renderPixelMountainsCanvas() const source = renderPixelMountainsCanvas(theme)
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
canvas.width = source.width canvas.width = source.width
canvas.height = source.height canvas.height = source.height
ctx.imageSmoothingEnabled = false ctx.imageSmoothingEnabled = false
ctx.drawImage(source, 0, 0) ctx.drawImage(source, 0, 0)
}
useEffect(() => {
drawCanvas(activeCanvasRef.current, activeTheme)
}, [activeTheme])
useEffect(() => {
drawCanvas(previousCanvasRef.current, previousTheme)
}, [previousTheme])
useEffect(() => {
function syncTheme() {
const theme = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
setActiveTheme((current) => {
if (theme === current) return current
window.clearTimeout(fadeTimerRef.current)
window.clearTimeout(skyTimerRef.current)
setPreviousTheme(current)
setSkyTransition(`${current}-to-${theme}`)
fadeTimerRef.current = window.setTimeout(() => setPreviousTheme(null), 440)
skyTimerRef.current = window.setTimeout(() => setSkyTransition(''), 900)
return theme
})
}
syncTheme()
const observer = new MutationObserver(syncTheme)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
observer.disconnect()
window.clearTimeout(fadeTimerRef.current)
window.clearTimeout(skyTimerRef.current)
}
}, []) }, [])
return <canvas ref={canvasRef} className="pixel-mountains" aria-hidden="true" /> return (
<div className="pixel-mountains" aria-hidden="true">
<div className={`pixel-sky pixel-sky-${skyTransition || activeTheme}`}>
<span className="pixel-sun" />
<span className="pixel-moon">
<span className="pixel-star pixel-star-a" />
<span className="pixel-star pixel-star-b" />
<span className="pixel-star pixel-star-c" />
</span>
</div>
{previousTheme ? (
<canvas
className="pixel-mountains-previous"
ref={previousCanvasRef}
/>
) : null}
<canvas className="pixel-mountains-active" ref={activeCanvasRef} />
</div>
)
} }
function TeamsPage({ leaderboard, navigate, teams }) { function TeamsPage({ leaderboard, navigate, teams }) {
@@ -2456,9 +2705,9 @@ function MiniLineChart({
<span>0</span> <span>0</span>
</div> </div>
<svg className="ml-8 h-28 w-[calc(100%-2rem)] overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${label} line chart`}> <svg className="ml-8 h-28 w-[calc(100%-2rem)] overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${label} line chart`}>
<path d={`M ${padding} ${height - padding} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeWidth="1" /> <path d={`M ${padding} ${height - padding} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeWidth="1" />
<path d={`M ${padding} ${padding} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeDasharray="4 5" strokeWidth="1" /> <path d={`M ${padding} ${padding} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeDasharray="4 5" strokeWidth="1" />
<path d={`M ${padding} ${height / 2} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeDasharray="4 5" strokeWidth="1" /> <path d={`M ${padding} ${height / 2} H ${width - padding}`} fill="none" stroke="var(--color-border)" strokeDasharray="4 5" strokeWidth="1" />
<path d={pathData} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" /> <path d={pathData} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" />
{points.map((point) => ( {points.map((point) => (
<circle <circle
@@ -2577,7 +2826,7 @@ function AnalyticsPeriodSection({
label="Clients" label="Clients"
metric="clients" metric="clients"
pointFormat={pointFormat} pointFormat={pointFormat}
stroke="#000000" stroke="var(--color-text)"
/> />
<MiniLineChart <MiniLineChart
accent="text-fury-cyan" accent="text-fury-cyan"
@@ -2612,6 +2861,7 @@ function ViewersPage({ viewers }) {
activity: activity24h, activity: activity24h,
topPages: data.top_pages || [], topPages: data.top_pages || [],
clients: data.clients || [], clients: data.clients || [],
themes: data.themes || [],
countries: data.countries_24h || [], countries: data.countries_24h || [],
locations: data.locations_24h || [], locations: data.locations_24h || [],
totals: { totals: {
@@ -2630,6 +2880,7 @@ function ViewersPage({ viewers }) {
activity: activity30d, activity: activity30d,
topPages: data.top_pages_30d || data.top_pages || [], topPages: data.top_pages_30d || data.top_pages || [],
clients: data.clients_30d || [], clients: data.clients_30d || [],
themes: data.themes_30d || data.themes || [],
countries: data.countries || [], countries: data.countries || [],
locations: data.locations || [], locations: data.locations || [],
totals: { totals: {
@@ -2660,7 +2911,7 @@ function ViewersPage({ viewers }) {
{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-fury-red text-fury-cyan 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)}
@@ -2726,6 +2977,11 @@ function ViewersPage({ viewers }) {
{country} {country}
</span> </span>
))} ))}
{(page.themes || []).map((theme) => (
<span className="rounded-md bg-surface px-2 py-1" key={theme.theme}>
{theme.theme} theme ({formatNumber(theme.count)})
</span>
))}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-right sm:grid-cols-1"> <div className="grid grid-cols-2 gap-2 text-right sm:grid-cols-1">
@@ -2763,7 +3019,7 @@ function ViewersPage({ viewers }) {
{viewer.browser} on {viewer.os} {viewer.browser} on {viewer.os}
</p> </p>
<p className="truncate text-xs text-text-soft"> <p className="truncate text-xs text-text-soft">
{viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.country || viewer.timezone || 'unknown location'} {viewer.device} · {viewer.screen || 'unknown screen'} · {viewer.theme || 'light'} theme · {viewer.country || viewer.timezone || 'unknown location'}
</p> </p>
</div> </div>
<p className="text-text-soft">{viewer.language || 'unknown language'}</p> <p className="text-text-soft">{viewer.language || 'unknown language'}</p>
@@ -2820,6 +3076,34 @@ function ViewersPage({ viewers }) {
</div> </div>
</div> </div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Themes</h2>
<p className="mt-1 text-sm text-text-soft">Light and dark mode usage over {periodData.title.toLowerCase()}</p>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-2">
{periodData.themes.map((theme) => {
const totalEvents = periodData.themes.reduce((sum, item) => sum + Number(item.events || 0), 0) || 1
const percent = Math.round((Number(theme.events || 0) / totalEvents) * 100)
return (
<div className="rounded-md border border-border bg-surface p-4" key={theme.theme}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold capitalize">{theme.theme} mode</p>
<p className="text-sm font-semibold text-fury-cyan">{percent}%</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-bg">
<div className="h-full rounded-full bg-fury-cyan" style={{ width: `${percent}%` }} />
</div>
<p className="mt-3 text-xs text-text-soft">
{formatNumber(theme.visitors || 0)} visitors · {formatNumber(theme.events || 0)} events
</p>
</div>
)
})}
{!periodData.themes.length ? <p className="text-sm text-text-soft">No theme data recorded yet</p> : null}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]"> <div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="rounded-lg border border-border bg-fury-white shadow-sm"> <div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4"> <div className="border-b border-surface px-5 py-4">
+357 -3
View File
@@ -82,6 +82,33 @@
--color-danger: #e82517; --color-danger: #e82517;
} }
:root[data-theme="dark"] {
--color-bg: #130d08;
--color-surface: #24170d;
--color-surface-alt: #34210f;
--color-fury-white: #18100a;
--color-fury-ice: #24170d;
--color-fury-blue: #34210f;
--color-fury-glow: #a86224;
--color-fury-cyan: #ff6a5f;
--color-fury-aqua: #ff8d84;
--color-fury-violet: #ffac4d;
--color-text: #fdb068;
--color-text-soft: #fff2e6;
--color-text-muted: #fee5cd;
--color-border: #68401f;
--color-ring: #ff8d84;
--color-shadow: rgba(255, 106, 95, 0.18);
--color-success: #58f0f5;
--color-warning: #f4ee3e;
--color-danger: #ff6a5f;
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
@font-face { @font-face {
font-display: swap; font-display: swap;
font-family: "SF Pro Text Local"; font-family: "SF Pro Text Local";
@@ -164,6 +191,7 @@ body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background: var(--color-bg);
font-family: font-family:
"SF Pro Rounded Local", "SF Pro Rounded", "SF Pro Text Local", "SF Pro Rounded Local", "SF Pro Rounded", "SF Pro Text Local",
"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter,
@@ -179,6 +207,15 @@ h3 {
} }
.pixel-mountains { .pixel-mountains {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.pixel-mountains canvas {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
@@ -186,16 +223,205 @@ h3 {
image-rendering: pixelated; image-rendering: pixelated;
object-fit: cover; object-fit: cover;
object-position: center bottom; object-position: center bottom;
pointer-events: none; transition: opacity 420ms ease;
}
.pixel-mountains-previous {
animation: pixelMountainsFadeOut 420ms ease forwards;
z-index: 1;
}
.pixel-mountains-active {
z-index: 0; z-index: 0;
} }
.pixel-sky {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.pixel-sun,
.pixel-moon {
position: absolute;
display: block;
image-rendering: pixelated;
transform: translate(-50%, -50%);
will-change: left, opacity, top, transform;
}
.pixel-sun {
left: 78%;
top: 18%;
width: 88px;
height: 88px;
background: #fdb068;
clip-path: polygon(
36% 0,
64% 0,
64% 12%,
84% 12%,
84% 36%,
100% 36%,
100% 64%,
84% 64%,
84% 84%,
64% 84%,
64% 100%,
36% 100%,
36% 84%,
12% 84%,
12% 64%,
0 64%,
0 36%,
12% 36%,
12% 12%,
36% 12%
);
box-shadow:
0 -36px 0 -28px #fdb068,
0 36px 0 -28px #fdb068,
-36px 0 0 -28px #fdb068,
36px 0 0 -28px #fdb068,
28px 28px 0 -30px #fdb068,
-28px 28px 0 -30px #fdb068,
28px -28px 0 -30px #fdb068,
-28px -28px 0 -30px #fdb068;
}
.pixel-sun::after {
position: absolute;
left: 18px;
top: 18px;
width: 24px;
height: 24px;
background: #fff2e6;
clip-path: polygon(25% 0, 75% 0, 75% 25%, 100% 25%, 100% 75%, 75% 75%, 75% 100%, 25% 100%, 25% 75%, 0 75%, 0 25%, 25% 25%);
content: "";
}
.pixel-moon {
left: 78%;
top: 18%;
width: 80px;
height: 80px;
background: #fff2e6;
clip-path: polygon(
35% 0,
72% 0,
72% 12%,
88% 12%,
88% 28%,
100% 28%,
100% 72%,
88% 72%,
88% 88%,
72% 88%,
72% 100%,
35% 100%,
35% 88%,
18% 88%,
18% 72%,
0 72%,
0 28%,
18% 28%,
18% 12%,
35% 12%
);
}
.pixel-moon::after {
position: absolute;
left: 30px;
top: -8px;
width: 74px;
height: 74px;
background: var(--color-bg);
clip-path: polygon(
35% 0,
72% 0,
72% 12%,
88% 12%,
88% 28%,
100% 28%,
100% 72%,
88% 72%,
88% 88%,
72% 88%,
72% 100%,
35% 100%,
35% 88%,
18% 88%,
18% 72%,
0 72%,
0 28%,
18% 28%,
18% 12%,
35% 12%
);
content: "";
}
.pixel-star {
position: absolute;
display: block;
width: 6px;
height: 6px;
background: #fee5cd;
}
.pixel-star-a {
right: -68px;
top: -32px;
}
.pixel-star-b {
left: -92px;
top: 22px;
}
.pixel-star-c {
right: -116px;
top: 54px;
}
.pixel-sky-light .pixel-sun,
.pixel-sky-dark .pixel-moon {
opacity: 1;
}
.pixel-sky-light .pixel-moon,
.pixel-sky-dark .pixel-sun {
opacity: 0;
}
.pixel-sky-light-to-dark .pixel-sun {
animation: sunSetWest 980ms cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
}
.pixel-sky-light-to-dark .pixel-moon {
animation: moonRiseEast 980ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.pixel-sky-dark-to-light .pixel-moon {
animation: moonSetWest 980ms cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
}
.pixel-sky-dark-to-light .pixel-sun {
animation: sunRiseEast 980ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
:root[data-theme="dark"] .pixel-mountains {
opacity: 1;
}
.tree { .tree {
width: min(100%, 500px); width: min(100%, 500px);
height: auto; height: auto;
display: block; display: block;
background: #fefde7; background: var(--color-fury-white);
border: 3px solid #fdca9b; border: 3px solid var(--color-border);
border-radius: 16px; border-radius: 16px;
padding: 12px; padding: 12px;
box-shadow: box-shadow:
@@ -203,6 +429,30 @@ h3 {
0 1px 3px rgba(50, 25, 1, 0.08); 0 1px 3px rgba(50, 25, 1, 0.08);
} }
:root[data-theme="dark"] .apricot-button-text {
color: #fff2e6;
}
:root[data-theme="dark"] .tree {
filter: brightness(0.78) sepia(0.18) saturate(0.92);
box-shadow:
0 4px 24px rgba(255, 106, 95, 0.14),
0 1px 3px rgba(0, 0, 0, 0.35);
}
.theme-toggle-mover-arc {
animation: themeToggleArc 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
:root.theme-transition,
:root.theme-transition *,
:root.theme-transition *::before,
:root.theme-transition *::after {
transition-duration: 420ms;
transition-property: background-color, border-color, box-shadow, color, fill, opacity, stroke, text-decoration-color;
transition-timing-function: ease;
}
.falling-leaves { .falling-leaves {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -227,3 +477,107 @@ h3 {
opacity: 0; opacity: 0;
} }
} }
@keyframes pixelMountainsFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes themeToggleArc {
0% {
transform: translate3d(var(--from-x), var(--from-y), 0);
}
50% {
transform: translate3d(var(--mid-x), var(--mid-y), 0);
}
100% {
transform: translate3d(var(--to-x), var(--to-y), 0);
}
}
@keyframes sunSetWest {
0% {
left: 78%;
opacity: 1;
top: 18%;
}
42% {
left: 48%;
opacity: 0.85;
top: 9%;
}
100% {
left: 12%;
opacity: 0;
top: 68%;
}
}
@keyframes moonRiseEast {
0% {
left: 93%;
opacity: 0;
top: 68%;
}
55% {
left: 86%;
opacity: 0.86;
top: 8%;
}
100% {
left: 78%;
opacity: 1;
top: 18%;
}
}
@keyframes moonSetWest {
0% {
left: 78%;
opacity: 1;
top: 18%;
}
42% {
left: 48%;
opacity: 0.85;
top: 9%;
}
100% {
left: 12%;
opacity: 0;
top: 68%;
}
}
@keyframes sunRiseEast {
0% {
left: 93%;
opacity: 0;
top: 68%;
}
55% {
left: 86%;
opacity: 0.86;
top: 8%;
}
100% {
left: 78%;
opacity: 1;
top: 18%;
}
}