aggressive data collection :PP
This commit is contained in:
+435
-51
@@ -13,6 +13,7 @@ const apiEndpoints = {
|
||||
uptime: '/api/uptime',
|
||||
viewers: '/api/viewers',
|
||||
viewerEvent: '/api/viewers/event',
|
||||
viewerDelete: '/api/viewers/delete',
|
||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||
@@ -29,7 +30,20 @@ const navItems = [
|
||||
]
|
||||
|
||||
const analyticsConsentKey = 'tssbot.analyticsConsent'
|
||||
const analyticsPreferencesKey = 'tssbot.analyticsPreferences'
|
||||
const analyticsPreferencesCookie = 'tssbot_analytics_preferences'
|
||||
const analyticsVisitorKey = 'tssbot.analyticsVisitor'
|
||||
const analyticsConsentVersion = 2
|
||||
|
||||
const defaultAnalyticsPreferences = {
|
||||
chosen: false,
|
||||
analytics: false,
|
||||
device: false,
|
||||
display: false,
|
||||
locale: false,
|
||||
referrer: false,
|
||||
version: analyticsConsentVersion,
|
||||
}
|
||||
|
||||
async function fetchJson(path, signal) {
|
||||
const response = await fetch(path, {
|
||||
@@ -50,6 +64,7 @@ function parseRoute(pathname = window.location.pathname) {
|
||||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||
if (pathname.startsWith('/teams/')) {
|
||||
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||
return { page: 'team', teamName }
|
||||
@@ -75,14 +90,107 @@ function bestTeamName(team) {
|
||||
return team?.tag_name || team?.short_name || team?.long_name || ''
|
||||
}
|
||||
|
||||
function storedConsent() {
|
||||
function normalizeAnalyticsPreferences(value) {
|
||||
if (!value || typeof value !== 'object') return { ...defaultAnalyticsPreferences }
|
||||
|
||||
return {
|
||||
chosen: Boolean(value.chosen) && value.version === analyticsConsentVersion,
|
||||
analytics: Boolean(value.analytics),
|
||||
device: Boolean(value.device),
|
||||
display: Boolean(value.display),
|
||||
locale: Boolean(value.locale),
|
||||
referrer: Boolean(value.referrer),
|
||||
version: analyticsConsentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
function browserPrivacySignalEnabled() {
|
||||
return Boolean(
|
||||
navigator.globalPrivacyControl ||
|
||||
navigator.doNotTrack === '1' ||
|
||||
window.doNotTrack === '1',
|
||||
)
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
try {
|
||||
return window.localStorage.getItem(analyticsConsentKey) || ''
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((item) => item.startsWith(`${encodeURIComponent(name)}=`))
|
||||
return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function writeCookie(name, value) {
|
||||
try {
|
||||
const sixMonths = 60 * 60 * 24 * 180
|
||||
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Max-Age=${sixMonths}; Path=/; SameSite=Lax`
|
||||
} catch {
|
||||
// Some browsers block cookies; local storage and in-memory state still work.
|
||||
}
|
||||
}
|
||||
|
||||
function storedAnalyticsPreferences() {
|
||||
const cookieValue = readCookie(analyticsPreferencesCookie)
|
||||
if (cookieValue) {
|
||||
try {
|
||||
return normalizeAnalyticsPreferences(JSON.parse(cookieValue))
|
||||
} catch {
|
||||
// Fall back through the older storage formats below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(analyticsPreferencesKey)
|
||||
if (stored) return normalizeAnalyticsPreferences(JSON.parse(stored))
|
||||
} catch {
|
||||
// Fall back through the older consent flag below.
|
||||
}
|
||||
|
||||
try {
|
||||
const legacyConsent = window.localStorage.getItem(analyticsConsentKey) || ''
|
||||
if (legacyConsent === 'analytics') {
|
||||
return {
|
||||
...defaultAnalyticsPreferences,
|
||||
chosen: true,
|
||||
analytics: true,
|
||||
device: true,
|
||||
display: true,
|
||||
locale: true,
|
||||
referrer: true,
|
||||
}
|
||||
}
|
||||
if (legacyConsent === 'declined') {
|
||||
return { ...defaultAnalyticsPreferences, chosen: true, analytics: false }
|
||||
}
|
||||
} catch {
|
||||
// Use the default prompt state.
|
||||
}
|
||||
|
||||
return { ...defaultAnalyticsPreferences }
|
||||
}
|
||||
|
||||
function persistAnalyticsPreferences(preferences) {
|
||||
const normalized = normalizeAnalyticsPreferences(preferences)
|
||||
const serialized = JSON.stringify(normalized)
|
||||
|
||||
writeCookie(analyticsPreferencesCookie, serialized)
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(analyticsPreferencesKey, serialized)
|
||||
window.localStorage.setItem(
|
||||
analyticsConsentKey,
|
||||
normalized.analytics ? 'analytics' : 'declined',
|
||||
)
|
||||
} catch {
|
||||
// Local storage can be blocked; the cookie and in-memory choice still apply.
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function stableId(storageKey) {
|
||||
try {
|
||||
const existing = window.localStorage.getItem(storageKey)
|
||||
@@ -98,6 +206,22 @@ function stableId(storageKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function storedVisitorId(storageKey) {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function forgetVisitorId(storageKey) {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// Storage may be unavailable; nothing else to clear locally.
|
||||
}
|
||||
}
|
||||
|
||||
const analyticsSessionId =
|
||||
window.crypto?.randomUUID?.() ||
|
||||
`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
@@ -135,6 +259,7 @@ function routeLabel(route) {
|
||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||
if (route.page === 'uptime') return 'Uptime'
|
||||
if (route.page === 'viewers') return 'viewers'
|
||||
if (route.page === 'privacy') return 'Privacy notice'
|
||||
return 'Home'
|
||||
}
|
||||
|
||||
@@ -193,7 +318,7 @@ function App() {
|
||||
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
||||
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
||||
const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null })
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState(() => storedConsent())
|
||||
const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences())
|
||||
const [teamQuery, setTeamQuery] = useState('')
|
||||
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
|
||||
const [profile, setProfile] = useState({
|
||||
@@ -231,16 +356,22 @@ function App() {
|
||||
? "Uptime | Toothless' TSS Bot"
|
||||
: route.page === 'viewers'
|
||||
? "viewers | Toothless' TSS Bot"
|
||||
: route.page === 'privacy'
|
||||
? "Privacy notice | Toothless' TSS Bot"
|
||||
: "Toothless' TSS Bot"
|
||||
|
||||
document.title = title
|
||||
}, [route.page, route.teamName])
|
||||
|
||||
useEffect(() => {
|
||||
if (analyticsConsent !== 'analytics') return
|
||||
if (!analyticsPreferences.analytics) return
|
||||
|
||||
const visitorId = stableId(analyticsVisitorKey)
|
||||
let stopped = false
|
||||
const deviceDetails = analyticsPreferences.device
|
||||
const displayDetails = analyticsPreferences.display
|
||||
const localeDetails = analyticsPreferences.locale
|
||||
const referrerDetails = analyticsPreferences.referrer
|
||||
|
||||
function sendViewerEvent(eventType) {
|
||||
if (stopped) return
|
||||
@@ -252,17 +383,20 @@ function App() {
|
||||
session_id: analyticsSessionId,
|
||||
page_path: window.location.pathname,
|
||||
page_title: routeLabel(route),
|
||||
referrer: document.referrer,
|
||||
browser: browserName(),
|
||||
os: operatingSystem(),
|
||||
device: deviceType(),
|
||||
screen: `${window.screen.width}x${window.screen.height}`,
|
||||
language: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
metadata: {
|
||||
color_depth: window.screen.colorDepth,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
},
|
||||
referrer: referrerDetails ? document.referrer : '',
|
||||
user_agent: deviceDetails ? navigator.userAgent : 'Not shared',
|
||||
browser: deviceDetails ? browserName() : 'Not shared',
|
||||
os: deviceDetails ? operatingSystem() : 'Not shared',
|
||||
device: deviceDetails ? deviceType() : 'Not shared',
|
||||
screen: displayDetails ? `${window.screen.width}x${window.screen.height}` : '',
|
||||
language: localeDetails ? navigator.language : '',
|
||||
timezone: localeDetails ? Intl.DateTimeFormat().resolvedOptions().timeZone : '',
|
||||
metadata: displayDetails
|
||||
? {
|
||||
color_depth: window.screen.colorDepth,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
}
|
||||
: {},
|
||||
}
|
||||
|
||||
fetch(apiEndpoints.viewerEvent, {
|
||||
@@ -285,7 +419,7 @@ function App() {
|
||||
window.clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [analyticsConsent, route])
|
||||
}, [analyticsPreferences, route])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event) => {
|
||||
@@ -633,13 +767,26 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function chooseAnalyticsConsent(value) {
|
||||
try {
|
||||
window.localStorage.setItem(analyticsConsentKey, value)
|
||||
} catch {
|
||||
// Local storage can be blocked; the in-memory choice still controls this session.
|
||||
function chooseAnalyticsConsent(preferences) {
|
||||
const previousVisitorId = storedVisitorId(analyticsVisitorKey)
|
||||
const nextPreferences = persistAnalyticsPreferences({ ...preferences, chosen: true })
|
||||
|
||||
if (!nextPreferences.analytics) {
|
||||
forgetVisitorId(analyticsVisitorKey)
|
||||
if (previousVisitorId) {
|
||||
fetch(apiEndpoints.viewerDelete, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
visitor_id: previousVisitorId,
|
||||
session_id: analyticsSessionId,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
setAnalyticsConsent(value)
|
||||
|
||||
setAnalyticsPreferences(nextPreferences)
|
||||
}
|
||||
|
||||
const activeNavPath =
|
||||
@@ -716,9 +863,10 @@ function App() {
|
||||
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
||||
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
||||
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
||||
</section>
|
||||
<Footer navigate={navigate} />
|
||||
<ConsentBanner consent={analyticsConsent} onChoose={chooseAnalyticsConsent} />
|
||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -743,57 +891,293 @@ function Footer({ navigate }) {
|
||||
>
|
||||
viewers
|
||||
</button>
|
||||
<button
|
||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||
onClick={() => navigate('/privacy')}
|
||||
type="button"
|
||||
>
|
||||
Privacy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsentBanner({ consent, onChoose }) {
|
||||
if (consent) {
|
||||
function ConsentBanner({ preferences, onChoose }) {
|
||||
const [isOpen, setIsOpen] = useState(() => !preferences.chosen)
|
||||
const [isConfiguring, setIsConfiguring] = useState(() => Boolean(preferences.chosen))
|
||||
const [draft, setDraft] = useState(() => normalizeAnalyticsPreferences(preferences))
|
||||
const privacySignalEnabled = browserPrivacySignalEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(normalizeAnalyticsPreferences(preferences))
|
||||
if (!preferences.chosen) {
|
||||
setIsOpen(true)
|
||||
setIsConfiguring(false)
|
||||
}
|
||||
}, [preferences])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
function updateDraft(key, value) {
|
||||
setDraft((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function savePreferences(nextPreferences) {
|
||||
onChoose(normalizeAnalyticsPreferences(nextPreferences))
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
className="fixed right-4 bottom-4 z-50 rounded-md border border-border bg-fury-white px-3 py-2 text-xs font-semibold text-text-soft shadow-sm transition hover:text-text"
|
||||
onClick={() => onChoose('')}
|
||||
onClick={() => {
|
||||
setIsConfiguring(true)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Privacy settings
|
||||
Cookie settings
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-fury-white shadow-[0_-8px_24px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-8 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="text-base font-semibold">Analytics consent</h2>
|
||||
<p className="mt-1 text-sm text-text-soft">
|
||||
We can track page views, live viewing state, browser, device, screen size,
|
||||
language, timezone, referrer, and pseudonymous identifiers so the public
|
||||
viewers page works. Raw IP addresses are not shown publicly.
|
||||
<div
|
||||
aria-labelledby="consent-title"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-md border border-border bg-fury-white p-5 text-text shadow-[0_24px_70px_rgba(0,0,0,0.24)] sm:p-6">
|
||||
<div className="max-w-xl">
|
||||
<h2 id="consent-title" className="text-xl font-semibold">
|
||||
Cookie and analytics settings
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-text-soft">
|
||||
We use a necessary cookie to remember these choices. You can also allow
|
||||
analytics for the public viewers page and choose which details are included.
|
||||
</p>
|
||||
{privacySignalEnabled ? (
|
||||
<p className="mt-2 text-sm font-semibold text-text">
|
||||
Your browser is signalling a privacy preference, so optional analytics stay
|
||||
off unless you explicitly allow them.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||
onClick={() => onChoose('declined')}
|
||||
type="button"
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
||||
onClick={() => onChoose('analytics')}
|
||||
type="button"
|
||||
>
|
||||
Allow analytics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isConfiguring ? (
|
||||
<>
|
||||
<div className="mt-5 space-y-3">
|
||||
<PreferenceToggle
|
||||
checked
|
||||
description="Stores this consent choice so the popup does not appear on every page."
|
||||
disabled
|
||||
label="Necessary cookie"
|
||||
/>
|
||||
<PreferenceToggle
|
||||
checked={draft.analytics}
|
||||
description="Sends page views, live viewing state, session ID, and pseudonymous visitor ID."
|
||||
label="Viewer analytics"
|
||||
onChange={(value) => updateDraft('analytics', value)}
|
||||
/>
|
||||
<PreferenceToggle
|
||||
checked={draft.device}
|
||||
description="Includes browser, operating system, and broad device type."
|
||||
disabled={!draft.analytics}
|
||||
label="Browser and device details"
|
||||
onChange={(value) => updateDraft('device', value)}
|
||||
/>
|
||||
<PreferenceToggle
|
||||
checked={draft.display}
|
||||
description="Includes screen size, viewport size, and colour depth."
|
||||
disabled={!draft.analytics}
|
||||
label="Screen details"
|
||||
onChange={(value) => updateDraft('display', value)}
|
||||
/>
|
||||
<PreferenceToggle
|
||||
checked={draft.locale}
|
||||
description="Includes browser language and timezone."
|
||||
disabled={!draft.analytics}
|
||||
label="Language and timezone"
|
||||
onChange={(value) => updateDraft('locale', value)}
|
||||
/>
|
||||
<PreferenceToggle
|
||||
checked={draft.referrer}
|
||||
description="Includes the page that linked you here when the browser provides it."
|
||||
disabled={!draft.analytics}
|
||||
label="Referrer"
|
||||
onChange={(value) => updateDraft('referrer', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text-soft transition hover:bg-surface hover:text-text"
|
||||
onClick={() => savePreferences({ ...defaultAnalyticsPreferences, chosen: true })}
|
||||
type="button"
|
||||
>
|
||||
Decline all
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
||||
onClick={() => savePreferences(draft)}
|
||||
type="button"
|
||||
>
|
||||
Save choices
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
||||
onClick={() =>
|
||||
savePreferences({
|
||||
chosen: true,
|
||||
analytics: true,
|
||||
device: true,
|
||||
display: true,
|
||||
locale: true,
|
||||
referrer: true,
|
||||
version: analyticsConsentVersion,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Allow all
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
className="rounded-md border border-border px-4 py-2 text-sm font-semibold text-text transition hover:bg-surface"
|
||||
onClick={() => setIsConfiguring(true)}
|
||||
type="button"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-bg transition hover:bg-fury-aqua"
|
||||
onClick={() =>
|
||||
savePreferences({
|
||||
chosen: true,
|
||||
analytics: true,
|
||||
device: true,
|
||||
display: true,
|
||||
locale: true,
|
||||
referrer: true,
|
||||
version: analyticsConsentVersion,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Allow all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreferenceToggle({ checked, description, disabled = false, label, onChange }) {
|
||||
return (
|
||||
<label
|
||||
className={`flex items-start gap-3 rounded-md border border-border bg-surface px-3 py-3 ${
|
||||
disabled ? 'opacity-65' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
checked={checked}
|
||||
className="mt-1 h-4 w-4 accent-fury-cyan"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange?.(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-text">{label}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-text-soft">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacyPage() {
|
||||
return (
|
||||
<section className="mx-auto max-w-4xl py-12">
|
||||
<div className="border-b border-border pb-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||
Privacy notice
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-bold">How Toothless' TSS Bot handles data</h1>
|
||||
<p className="mt-3 text-text-soft">
|
||||
Controller: Heidi. Contact: Discord <span className="font-semibold text-text">clippiii</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 text-sm leading-6 text-text-soft">
|
||||
<PrivacySection title="What we collect">
|
||||
Necessary cookies store your cookie 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,
|
||||
screen, language/timezone, and referrer details are included.
|
||||
</PrivacySection>
|
||||
|
||||
<PrivacySection title="Why we collect it">
|
||||
Necessary cookies are used to remember your privacy choices. Optional viewer
|
||||
analytics are used only to power the public viewers page, understand basic site
|
||||
activity, and keep abuse low.
|
||||
</PrivacySection>
|
||||
|
||||
<PrivacySection title="Legal basis">
|
||||
Necessary cookies are used because they are needed to remember your consent
|
||||
preferences. Optional analytics only run with your consent, and you can withdraw
|
||||
that consent from the Cookie settings button at any time.
|
||||
</PrivacySection>
|
||||
|
||||
<PrivacySection title="Retention and deletion">
|
||||
Analytics events are automatically deleted after the configured retention period,
|
||||
currently documented as 30 days by default. Active viewer sessions expire after a
|
||||
short inactivity window. If you decline analytics after previously allowing them,
|
||||
the site asks the server to delete records linked to your current visitor and
|
||||
session IDs, and removes the local visitor ID from your browser.
|
||||
</PrivacySection>
|
||||
|
||||
<PrivacySection title="Sharing and transfers">
|
||||
Viewer analytics are stored by this site and are not sold. Public viewer pages show
|
||||
only consented analytics fields and never show raw IP addresses. Hosting providers
|
||||
may process server logs as part of running the site.
|
||||
</PrivacySection>
|
||||
|
||||
<PrivacySection title="Your rights">
|
||||
You can ask for access, correction, deletion, restriction, objection, portability,
|
||||
or withdrawal of consent by contacting Heidi on Discord at clippiii. If you are in
|
||||
the UK, you can also complain to the ICO. If you are in the EU or EEA, you can
|
||||
complain to your local data protection authority.
|
||||
</PrivacySection>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacySection({ children, title }) {
|
||||
return (
|
||||
<article className="border-l border-border pl-4">
|
||||
<h2 className="text-lg font-semibold text-text">{title}</h2>
|
||||
<p className="mt-2">{children}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Landing({ live, matches, navigate }) {
|
||||
const treeRef = useRef(null)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user