|
| 1 | +diff --git a/src/components/Footer.astro b/src/components/Footer.astro |
| 2 | +index dc15831..b2c1cc2 100644 |
| 3 | +--- a/src/components/Footer.astro |
| 4 | ++++ b/src/components/Footer.astro |
| 5 | +@@ -7,5 +7,9 @@ const today = new Date(); |
| 6 | + <p> |
| 7 | + © {today.getFullYear()} Vincenzo Di Perna. All rights reserved. |
| 8 | + </p> |
| 9 | ++ |
| 10 | ++ <a class="link link-hover" href="/privacy"> |
| 11 | ++ Privacy & analytics |
| 12 | ++ </a> |
| 13 | + </div> |
| 14 | + </footer> |
| 15 | +diff --git a/src/components/GoogleAnalytics.astro b/src/components/GoogleAnalytics.astro |
| 16 | +new file mode 100644 |
| 17 | +index 0000000..12d8eca |
| 18 | +--- /dev/null |
| 19 | ++++ b/src/components/GoogleAnalytics.astro |
| 20 | +@@ -0,0 +1,153 @@ |
| 21 | ++--- |
| 22 | ++// Replace this placeholder with your Google Analytics 4 Measurement ID. |
| 23 | ++// Example: const GA_MEASUREMENT_ID = "G-ABC123DE45"; |
| 24 | ++const GA_MEASUREMENT_ID = "G-XXXXXXXXXX"; |
| 25 | ++const isConfigured = GA_MEASUREMENT_ID.startsWith("G-") && GA_MEASUREMENT_ID !== "G-XXXXXXXXXX"; |
| 26 | ++--- |
| 27 | ++ |
| 28 | ++{isConfigured && ( |
| 29 | ++ <> |
| 30 | ++ <div |
| 31 | ++ id="analytics-consent-banner" |
| 32 | ++ class="fixed inset-x-0 bottom-0 z-[10000] hidden border-t border-base-300 bg-base-100/95 px-4 py-4 shadow-2xl backdrop-blur md:px-6" |
| 33 | ++ role="region" |
| 34 | ++ aria-label="Analytics consent" |
| 35 | ++ > |
| 36 | ++ <div class="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between"> |
| 37 | ++ <div class="text-sm leading-relaxed text-base-content"> |
| 38 | ++ <p class="font-semibold">Privacy-friendly analytics</p> |
| 39 | ++ <p class="opacity-80"> |
| 40 | ++ I use Google Analytics to understand aggregate site usage, file downloads, |
| 41 | ++ and outbound clicks. Analytics loads only if you accept. |
| 42 | ++ </p> |
| 43 | ++ </div> |
| 44 | ++ |
| 45 | ++ <div class="flex shrink-0 gap-2"> |
| 46 | ++ <button id="analytics-consent-reject" class="btn btn-sm btn-outline" type="button"> |
| 47 | ++ Reject |
| 48 | ++ </button> |
| 49 | ++ <button id="analytics-consent-accept" class="btn btn-sm btn-primary" type="button"> |
| 50 | ++ Accept analytics |
| 51 | ++ </button> |
| 52 | ++ </div> |
| 53 | ++ </div> |
| 54 | ++ </div> |
| 55 | ++ |
| 56 | ++ <script define:vars={{ GA_MEASUREMENT_ID }}> |
| 57 | ++ (() => { |
| 58 | ++ if (window.__vindipeGoogleAnalyticsInitialized) return; |
| 59 | ++ window.__vindipeGoogleAnalyticsInitialized = true; |
| 60 | ++ |
| 61 | ++ const measurementId = GA_MEASUREMENT_ID; |
| 62 | ++ const consentKey = "vindipe_analytics_consent"; |
| 63 | ++ const acceptedValue = "accepted"; |
| 64 | ++ const rejectedValue = "rejected"; |
| 65 | ++ let analyticsLoaded = false; |
| 66 | ++ |
| 67 | ++ const isLocalHost = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname); |
| 68 | ++ |
| 69 | ++ function getConsent() { |
| 70 | ++ try { |
| 71 | ++ return window.localStorage.getItem(consentKey); |
| 72 | ++ } catch { |
| 73 | ++ return null; |
| 74 | ++ } |
| 75 | ++ } |
| 76 | ++ |
| 77 | ++ function setConsent(value) { |
| 78 | ++ try { |
| 79 | ++ window.localStorage.setItem(consentKey, value); |
| 80 | ++ } catch { |
| 81 | ++ // If localStorage is unavailable, continue without persisting consent. |
| 82 | ++ } |
| 83 | ++ } |
| 84 | ++ |
| 85 | ++ function hideBanner() { |
| 86 | ++ document.getElementById("analytics-consent-banner")?.classList.add("hidden"); |
| 87 | ++ } |
| 88 | ++ |
| 89 | ++ function showBanner() { |
| 90 | ++ document.getElementById("analytics-consent-banner")?.classList.remove("hidden"); |
| 91 | ++ } |
| 92 | ++ |
| 93 | ++ function trackPageView() { |
| 94 | ++ if (typeof window.gtag !== "function") return; |
| 95 | ++ |
| 96 | ++ window.gtag("event", "page_view", { |
| 97 | ++ page_title: document.title, |
| 98 | ++ page_location: window.location.href, |
| 99 | ++ page_path: window.location.pathname + window.location.search, |
| 100 | ++ }); |
| 101 | ++ } |
| 102 | ++ |
| 103 | ++ function loadGoogleAnalytics() { |
| 104 | ++ if (analyticsLoaded || isLocalHost) return; |
| 105 | ++ analyticsLoaded = true; |
| 106 | ++ |
| 107 | ++ window.dataLayer = window.dataLayer || []; |
| 108 | ++ window.gtag = function gtag(){ window.dataLayer.push(arguments); }; |
| 109 | ++ |
| 110 | ++ window.gtag("consent", "default", { |
| 111 | ++ analytics_storage: "granted", |
| 112 | ++ ad_storage: "denied", |
| 113 | ++ ad_user_data: "denied", |
| 114 | ++ ad_personalization: "denied", |
| 115 | ++ functionality_storage: "granted", |
| 116 | ++ security_storage: "granted", |
| 117 | ++ }); |
| 118 | ++ |
| 119 | ++ window.gtag("js", new Date()); |
| 120 | ++ window.gtag("config", measurementId, { |
| 121 | ++ send_page_view: false, |
| 122 | ++ }); |
| 123 | ++ |
| 124 | ++ const script = document.createElement("script"); |
| 125 | ++ script.async = true; |
| 126 | ++ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`; |
| 127 | ++ script.onload = trackPageView; |
| 128 | ++ document.head.appendChild(script); |
| 129 | ++ } |
| 130 | ++ |
| 131 | ++ function bindConsentButtons() { |
| 132 | ++ document.getElementById("analytics-consent-accept")?.addEventListener("click", () => { |
| 133 | ++ setConsent(acceptedValue); |
| 134 | ++ hideBanner(); |
| 135 | ++ loadGoogleAnalytics(); |
| 136 | ++ }); |
| 137 | ++ |
| 138 | ++ document.getElementById("analytics-consent-reject")?.addEventListener("click", () => { |
| 139 | ++ setConsent(rejectedValue); |
| 140 | ++ hideBanner(); |
| 141 | ++ }); |
| 142 | ++ } |
| 143 | ++ |
| 144 | ++ function initAnalyticsConsent() { |
| 145 | ++ bindConsentButtons(); |
| 146 | ++ |
| 147 | ++ const consent = getConsent(); |
| 148 | ++ if (consent === acceptedValue) { |
| 149 | ++ hideBanner(); |
| 150 | ++ loadGoogleAnalytics(); |
| 151 | ++ return; |
| 152 | ++ } |
| 153 | ++ |
| 154 | ++ if (consent === rejectedValue || isLocalHost) { |
| 155 | ++ hideBanner(); |
| 156 | ++ return; |
| 157 | ++ } |
| 158 | ++ |
| 159 | ++ showBanner(); |
| 160 | ++ } |
| 161 | ++ |
| 162 | ++ initAnalyticsConsent(); |
| 163 | ++ |
| 164 | ++ document.addEventListener("astro:page-load", () => { |
| 165 | ++ if (getConsent() === acceptedValue) { |
| 166 | ++ loadGoogleAnalytics(); |
| 167 | ++ trackPageView(); |
| 168 | ++ } |
| 169 | ++ }); |
| 170 | ++ })(); |
| 171 | ++ </script> |
| 172 | ++ </> |
| 173 | ++)} |
| 174 | +diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro |
| 175 | +index f699ab2..5b3b80e 100644 |
| 176 | +--- a/src/layouts/BaseLayout.astro |
| 177 | ++++ b/src/layouts/BaseLayout.astro |
| 178 | +@@ -4,6 +4,7 @@ import Header from "../components/Header.astro"; |
| 179 | + import Footer from "../components/Footer.astro"; |
| 180 | + import SideBar from "../components/SideBar.astro"; |
| 181 | + import NetworkBackground from "../components/NetworkBackground.astro"; |
| 182 | ++import GoogleAnalytics from "../components/GoogleAnalytics.astro"; |
| 183 | + import { ViewTransitions } from "astro:transitions"; |
| 184 | + |
| 185 | + import { SITE_TITLE, SITE_DESCRIPTION, TRANSITION_API } from "../config"; |
| 186 | +@@ -49,5 +50,7 @@ const imageURL = new URL(image, site).toString(); |
| 187 | + |
| 188 | + {includeSidebar && <SideBar sideBarActiveItemID={sideBarActiveItemID} />} |
| 189 | + </div> |
| 190 | ++ |
| 191 | ++ <GoogleAnalytics /> |
| 192 | + </body> |
| 193 | + </html> |
| 194 | +\ No newline at end of file |
| 195 | +diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro |
| 196 | +new file mode 100644 |
| 197 | +index 0000000..0f7fe95 |
| 198 | +--- /dev/null |
| 199 | ++++ b/src/pages/privacy.astro |
| 200 | +@@ -0,0 +1,48 @@ |
| 201 | ++--- |
| 202 | ++import BaseLayout from "../layouts/BaseLayout.astro"; |
| 203 | ++--- |
| 204 | ++ |
| 205 | ++<BaseLayout |
| 206 | ++ title="Privacy & analytics | Vincenzo Di Perna" |
| 207 | ++ description="Privacy and analytics information for the personal website of Vincenzo Di Perna." |
| 208 | ++> |
| 209 | ++ <section class="mb-10"> |
| 210 | ++ <h1 class="text-4xl font-bold mb-4">Privacy & analytics</h1> |
| 211 | ++ |
| 212 | ++ <div class="prose max-w-none leading-relaxed"> |
| 213 | ++ <p> |
| 214 | ++ This is the personal website of Vincenzo Di Perna. The site is hosted as |
| 215 | ++ a static website and is designed to collect only the information needed |
| 216 | ++ to understand whether the website is useful to visitors. |
| 217 | ++ </p> |
| 218 | ++ |
| 219 | ++ <h2>Analytics</h2> |
| 220 | ++ |
| 221 | ++ <p> |
| 222 | ++ This site may use Google Analytics 4 to collect aggregate usage statistics, |
| 223 | ++ such as page views, file downloads, outbound link clicks, approximate |
| 224 | ++ geography, device category, browser, and referral sources. |
| 225 | ++ </p> |
| 226 | ++ |
| 227 | ++ <p> |
| 228 | ++ Analytics is loaded only after you accept the analytics banner. If you |
| 229 | ++ reject analytics, the Google Analytics tag is not loaded by this site. |
| 230 | ++ </p> |
| 231 | ++ |
| 232 | ++ <h2>Downloads and outbound links</h2> |
| 233 | ++ |
| 234 | ++ <p> |
| 235 | ++ Analytics may record aggregate events such as opening the CV PDF, opening |
| 236 | ++ thesis PDFs, or clicking external links to GitHub, Zenodo, DOI pages, |
| 237 | ++ LinkedIn, or related research resources. |
| 238 | ++ </p> |
| 239 | ++ |
| 240 | ++ <h2>Contact</h2> |
| 241 | ++ |
| 242 | ++ <p> |
| 243 | ++ If you contact me by email, the information you send is processed only |
| 244 | ++ for communication purposes. |
| 245 | ++ </p> |
| 246 | ++ </div> |
| 247 | ++ </section> |
| 248 | ++</BaseLayout> |
0 commit comments