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 ( + + ) +} + +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 (
-
+
+
{route.page === 'home' ? ( @@ -1514,9 +1698,9 @@ function PrivacyPage() {
- 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 - 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. Technical diagnostics can include HTTP version, request protocol details, content negotiation headers, browser privacy signals, network quality, touch @@ -1524,7 +1708,7 @@ function PrivacyPage() { - 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 activity, and keep abuse low. @@ -1601,7 +1785,7 @@ function Landing({
@@ -2763,7 +3019,7 @@ function ViewersPage({ viewers }) { {viewer.browser} on {viewer.os}

- {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'}

{viewer.language || 'unknown language'}

@@ -2820,6 +3076,34 @@ function ViewersPage({ viewers }) {
+
+
+

Themes

+

Light and dark mode usage over {periodData.title.toLowerCase()}

+
+
+ {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 ( +
+
+

{theme.theme} mode

+

{percent}%

+
+
+
+
+

+ {formatNumber(theme.visitors || 0)} visitors · {formatNumber(theme.events || 0)} events +

+
+ ) + })} + {!periodData.themes.length ?

No theme data recorded yet

: null} +
+
+
diff --git a/src/styles.css b/src/styles.css index 0dd55b1..83ae0f8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -82,6 +82,33 @@ --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-display: swap; font-family: "SF Pro Text Local"; @@ -164,6 +191,7 @@ body { margin: 0; min-width: 320px; min-height: 100vh; + background: var(--color-bg); font-family: "SF Pro Rounded Local", "SF Pro Rounded", "SF Pro Text Local", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, @@ -179,6 +207,15 @@ h3 { } .pixel-mountains { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; +} + +.pixel-mountains canvas { position: absolute; inset: 0; width: 100%; @@ -186,16 +223,205 @@ h3 { image-rendering: pixelated; object-fit: cover; 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; } +.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 { width: min(100%, 500px); height: auto; display: block; - background: #fefde7; - border: 3px solid #fdca9b; + background: var(--color-fury-white); + border: 3px solid var(--color-border); border-radius: 16px; padding: 12px; box-shadow: @@ -203,6 +429,30 @@ h3 { 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 { position: absolute; inset: 0; @@ -227,3 +477,107 @@ h3 { 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%; + } +}