fix
This commit is contained in:
+39
-4
@@ -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
@@ -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
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user