diff --git a/server.cjs b/server.cjs
index 0195fac..f4d3744 100644
--- a/server.cjs
+++ b/server.cjs
@@ -259,6 +259,7 @@ function ensureAnalyticsDb() {
os text not null default 'Unknown',
device text not null default 'Desktop',
screen text not null default '',
+ theme text not null default 'light',
language text not null default '',
timezone text not null default '',
country text not null default '',
@@ -284,6 +285,7 @@ function ensureAnalyticsDb() {
os text not null default 'Unknown',
device text not null default 'Desktop',
screen text not null default '',
+ theme text not null default 'light',
language text not null default '',
timezone 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 viewer_events 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 {
analyticsDb.exec(statement)
@@ -578,6 +582,10 @@ function numberHeader(req, name, min, max) {
return value
}
+function sanitizeTheme(value) {
+ return value === 'dark' ? 'dark' : 'light'
+}
+
const CITY_COORDINATE_OVERRIDES = new Map([
['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),
device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80),
screen: sanitizeText(payload.screen, 40),
+ theme: sanitizeTheme(payload.theme),
language: sanitizeText(payload.language, 40),
timezone: sanitizeText(payload.timezone, 80),
country: location.country,
@@ -989,22 +998,22 @@ function recordViewerEvent(req, payload) {
db.prepare(`
insert into viewer_events
(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)
values
(@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)
`).run({ ...event, occurred_at: now })
db.prepare(`
insert into active_viewers
(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)
values
(@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)
on conflict(session_id) do update set
last_seen_at = excluded.last_seen_at,
@@ -1016,6 +1025,7 @@ function recordViewerEvent(req, payload) {
os = excluded.os,
device = excluded.device,
screen = excluded.screen,
+ theme = excluded.theme,
language = excluded.language,
timezone = excluded.timezone,
country = excluded.country,
@@ -1077,6 +1087,7 @@ function viewerDashboard() {
max(os) as os,
max(device) as device,
max(screen) as screen,
+ max(theme) as theme,
max(language) as language,
max(timezone) as timezone,
max(country) as country,
@@ -1102,6 +1113,7 @@ function viewerDashboard() {
os: row.os,
device: row.device,
screen: row.screen,
+ theme: sanitizeTheme(row.theme),
language: row.language,
timezone: row.timezone,
country: row.country,
@@ -1121,12 +1133,14 @@ function viewerDashboard() {
visitors: new Set(),
clients: new Map(),
countries: new Set(),
+ themes: new Map(),
last_seen_at: viewer.last_seen_at,
}
existing.viewers += viewer.sessions || 1
existing.visitors.add(viewer.visitor)
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}`
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()) {
@@ -1142,6 +1156,9 @@ function viewerDashboard() {
viewers: page.viewers,
visitors: page.visitors.size,
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())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 4)
@@ -1188,6 +1205,22 @@ function viewerDashboard() {
limit 12
`).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(`
select
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
@@ -1447,6 +1480,8 @@ function viewerDashboard() {
top_pages_30d: topPages30d,
clients,
clients_30d: clients30d,
+ themes,
+ themes_30d: themes30d,
activity_24h: activity24hWithLabels,
activity_30d: activityWithLocations,
countries_24h: countries24h,
diff --git a/src/App.jsx b/src/App.jsx
index 9043fb6..157530d 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -37,6 +37,8 @@ const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
const analyticsConsentVersion = 3
+const themePreferenceKey = 'tssbot.theme'
+const themePreferenceCookie = 'tssbot_theme'
const liveRefreshMs = 15000
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() {
const cookieValue = readCookie(analyticsPreferencesCookie)
if (cookieValue) {
@@ -374,6 +404,98 @@ function Stat({ label, value }) {
)
}
+function ThemeToggle({ theme, onThemeChange }) {
+ const isDark = theme === 'dark'
+
+ return (
+ onThemeChange(isDark ? 'light' : 'dark')}
+ title={isDark ? 'Light mode' : 'Dark mode'}
+ type="button"
+ >
+ {isDark ? '☾' : '☼'}
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
+
function TurnstileWidget({ siteKey, theme = 'auto', size = 'normal', action, onVerify, onExpire, onError, resetSignal = 0 }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
@@ -545,7 +667,9 @@ function AppContent() {
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
+ const [theme, setTheme] = useState(() => storedThemePreference())
const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40)
+ const [themeTogglePosition, setThemeTogglePosition] = useState(() => defaultThemeTogglePosition())
const [teamQuery, setTeamQuery] = useState('')
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
const [teamSearchResults, setTeamSearchResults] = useState([])
@@ -561,11 +685,29 @@ function AppContent() {
)
const matches = live.data?.matches || []
const liveRef = useRef(live)
+ const navPillRef = useRef(null)
+ const themeRef = useRef(theme)
+ const themeTransitionTimerRef = useRef(null)
useEffect(() => {
liveRef.current = 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 } = {}) {
if (replace) {
window.history.replaceState({}, '', path)
@@ -647,6 +789,7 @@ function AppContent() {
locale: localeDetails,
referrer: referrerDetails,
diagnostics: diagnosticsDetails,
+ theme: themeRef.current,
},
}
@@ -695,6 +838,7 @@ function AppContent() {
screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '',
language: localeDetails ? navigator.language : '',
timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
+ theme: themeRef.current,
metadata,
}
@@ -1150,6 +1294,16 @@ function AppContent() {
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 =
route.page === 'team'
? '/teams'
@@ -1160,6 +1314,28 @@ function AppContent() {
: window.location.pathname
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 (