Files
InsanityLab-web/src/layouts/Base.astro
T
Adriano bcc965655d Aggiunge integrazione Google Tag Manager attivabile via env
GTM viene caricato nel layout Base solo se la variabile GTM_ID è valorizzata.
L'ID è letto a runtime da process.env (non inlinato a build-time), così si
attiva/disattiva senza ricompilare. Con GTM_ID vuoto il sito non carica alcuno
script Google.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:08:58 +02:00

340 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/montserrat/700.css';
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/600.css';
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { getDb } from '../lib/db';
import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
interface Props { title: string; description?: string }
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
const user = tok ? getSessionUser(getDb(), tok) : null;
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
const showTags = canTags && wantTags;
// Google Tag Manager: l'ID container arriva da GTM_ID (.env), letto a runtime via process.env
// perché in SSR le PUBLIC_* di Vite verrebbero inlinate a build-time. Se vuoto GTM non carica.
const gtmId = process.env.GTM_ID;
---
<!doctype html>
<html lang="it">
<head>
{gtmId && (
<script is:inline set:html={`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtmId}');`} />
)}
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — InsanityLab</title>
<meta name="description" content={description} />
<meta property="og:title" content={`${title} — InsanityLab`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:image" content={new URL('/img/og.jpg', Astro.site)} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap.xml" />
<script is:inline>document.documentElement.classList.add('js');</script>
</head>
<body>
{gtmId && (
<noscript set:html={`<iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}" height="0" width="0" style="display:none;visibility:hidden"></iframe>`} />
)}
<Header user={user} showTags={showTags} canTags={canTags} />
<main><slot /></main>
<Footer />
<script>
// Reveal allo scroll: trigger a -100px dal viewport come il template Prowess.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('is-inview');
io.unobserve(e.target);
// A reveal concluso l'attributo va tolto, così la transition del reveal
// non rallenta gli hover su transform del componente.
e.target.addEventListener('transitionend', () => {
e.target.removeAttribute('data-reveal');
}, { once: true });
}
}
}, { rootMargin: '0px 0px -100px 0px' });
document.querySelectorAll('[data-reveal], .section-heading').forEach((el) => io.observe(el));
const parallaxEls = document.querySelectorAll<HTMLElement>('[data-parallax]');
if (parallaxEls.length && matchMedia('(min-width: 992px)').matches
&& !matchMedia('(prefers-reduced-motion: reduce)').matches) {
let raf = 0;
const update = () => {
raf = 0;
for (const el of parallaxEls) {
const r = el.getBoundingClientRect();
// Clamp entro il margine di sfondo (inset -120px) per non scoprire i bordi
const y = Math.max(-110, Math.min(110, (r.top + r.height / 2 - innerHeight / 2) * -0.3));
el.style.setProperty('--parallax-y', `${y.toFixed(1)}px`);
}
};
addEventListener('scroll', () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
}
</script>
{showTags && (
<style is:global>
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; border-radius: 3px; }
.tag-badge:hover { opacity: 1; }
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; border-radius: 2px; }
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(28,20,16,.55); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 20px; }
.tag-modal[hidden] { display: none; }
.tag-modal__box { background: #fdfbf7; color: #2b2320; width: min(600px, 94vw); max-height: 88vh; overflow: auto;
border-radius: 14px; box-shadow: 0 24px 60px rgba(28,20,16,.35); font-family: system-ui, sans-serif; }
.tag-modal__head { padding: 20px 24px 16px; border-bottom: 1px solid #ece4d8; }
.tag-modal__head h3 { margin: 0; font: 600 1.05rem/1.3 system-ui, sans-serif; letter-spacing: .01em; }
.tag-modal__head .tm-tagchip { display: inline-block; margin-top: 8px; background: #efe8dc; color: #6b5b48;
font: 500 11px/1.6 ui-monospace, monospace; padding: 2px 9px; border-radius: 999px; word-break: break-all; }
.tag-modal__head .guid { font: 10px/1.5 ui-monospace, monospace; color: #a99f90; margin: 6px 0 0; }
.tag-modal__body { padding: 20px 24px; }
.tag-modal__box textarea { width: 100%; min-height: 150px; font: inherit; font-size: .95rem; line-height: 1.5;
padding: 12px 14px; box-sizing: border-box; border: 1px solid #dcd2c4; border-radius: 10px; background: #fff;
resize: vertical; color: #2b2320; }
.tag-modal__box textarea:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px 14px; margin-top: 18px; }
.tm-field { display: flex; flex-direction: column; gap: 5px; }
.tm-field__label { font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: #8a7c68; }
.tm-field select { appearance: none; width: 100%; padding: 9px 30px 9px 12px; border: 1px solid #dcd2c4; border-radius: 9px;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%238a7c68' stroke-width='1.6'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E") no-repeat right 11px center;
font: inherit; font-size: .9rem; color: #2b2320; cursor: pointer; }
.tm-field select:focus { outline: 2px solid #9c8b70; outline-offset: 1px; border-color: #9c8b70; }
.tm-field select.is-set { border-color: #9c8b70; background-color: #f6f1e8; font-weight: 600; }
.tm-field select.is-estimated { border-style: dashed; border-color: #c4b79f; color: #6b5b48; }
.tm-note { margin: 12px 0 0; font: 400 12px/1.5 system-ui, sans-serif; color: #8a7c68; }
.tag-modal__box input[type=file] { display: block; width: 100%; font: inherit; font-size: .9rem; padding: 10px;
border: 1px dashed #cbbfad; border-radius: 10px; background: #fff; box-sizing: border-box; }
.tm-del { margin-top: 12px; padding: 8px 14px; border: 0; border-radius: 8px; background: #b4443a; color: #fff; cursor: pointer; font: 600 .85rem system-ui, sans-serif; }
.tag-modal__msg { color: #b4443a; margin: 12px 24px 0; min-height: 18px; font-size: .88rem; }
.tag-modal__row { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 24px 22px; }
.tag-modal__row button { padding: 10px 20px; border: 0; border-radius: 9px; cursor: pointer; font: 600 .9rem system-ui, sans-serif; }
.tag-modal__save { background: #3a2d26; color: #fff; }
.tag-modal__save:hover { background: #2b211b; }
.tag-modal__cancel { background: #ece4d8; color: #5b4f42; }
.tag-modal__cancel:hover { background: #e0d6c6; }
</style>
<div class="tag-modal" id="tag-modal" hidden>
<div class="tag-modal__box">
<div class="tag-modal__head">
<h3>Modifica contenuto</h3>
<span class="tm-tagchip" id="tm-tag"></span>
<p class="guid" id="tm-guid"></p>
</div>
<div class="tag-modal__body" id="tm-body"></div>
<div class="tag-modal__msg" id="tm-msg"></div>
<div class="tag-modal__row">
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
</div>
</div>
</div>
<script is:inline>
(() => {
const STYLE_GROUPS = { size: ['s','m','l','xl','xxl'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right','justify'] };
const GROUP_LABELS = { size: 'Dimensione', color: 'Colore', weight: 'Peso', style: 'Stile', align: 'Allineamento' };
const VALUE_LABELS = {
size: { s: 'S', m: 'M', l: 'L', xl: 'XL', xxl: 'XXL' },
weight: { normal: 'Normale', bold: 'Grassetto' },
style: { normal: 'Normale', italic: 'Corsivo' },
align: { left: 'Sinistra', center: 'Centro', right: 'Destra', justify: 'Giustificato' },
color: { accent: 'Accento', 'accent-dark': 'Accento scuro', dark: 'Scuro', heading: 'Titolo', text: 'Testo', 'text-light': 'Testo chiaro', white: 'Bianco' },
};
const valLabel = (g, v) => (VALUE_LABELS[g] && VALUE_LABELS[g][v]) || v;
// Stima il preset più vicino all'aspetto REALE dell'elemento (getComputedStyle).
const px = (s) => parseFloat(s) || 0;
const rgb = (s) => { const m = String(s).match(/-?\d+(\.\d+)?/g); return m ? m.slice(0, 3).map(Number) : null; };
const SIZE_SCALE = { s: 0.85, m: 1, l: 1.25, xl: 1.5, xxl: 2 };
let colorProbe = null;
function nearestSize(el) {
const cs = getComputedStyle(el);
const ref = el.parentElement ? px(getComputedStyle(el.parentElement).fontSize) : px(cs.fontSize);
const ratio = ref ? px(cs.fontSize) / ref : 1;
let best = 'm', bd = Infinity;
for (const k in SIZE_SCALE) { const d = Math.abs(SIZE_SCALE[k] - ratio); if (d < bd) { bd = d; best = k; } }
return best;
}
function nearestColor(el) {
const a = rgb(getComputedStyle(el).color); if (!a) return null;
if (!colorProbe) { colorProbe = document.createElement('span'); colorProbe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden'; document.body.appendChild(colorProbe); }
let best = null, bd = Infinity;
STYLE_GROUPS.color.forEach((c) => {
colorProbe.className = 'cs-color-' + c;
const p = rgb(getComputedStyle(colorProbe).color); if (!p) return;
const d = (p[0]-a[0])**2 + (p[1]-a[1])**2 + (p[2]-a[2])**2;
if (d < bd) { bd = d; best = c; }
});
colorProbe.className = '';
return best;
}
function effective(group, el) {
const cs = getComputedStyle(el);
if (group === 'size') return nearestSize(el);
if (group === 'color') return nearestColor(el);
if (group === 'weight') return parseInt(cs.fontWeight, 10) >= 600 ? 'bold' : 'normal';
if (group === 'style') return cs.fontStyle.indexOf('italic') >= 0 ? 'italic' : 'normal';
if (group === 'align') { let x = cs.textAlign; if (x === 'start') x = 'left'; else if (x === 'end') x = 'right'; return ['left','center','right','justify'].indexOf(x) >= 0 ? x : 'left'; }
return null;
}
const modal = document.getElementById('tag-modal');
const tmTag = document.getElementById('tm-tag');
const tmGuid = document.getElementById('tm-guid');
const tmBody = document.getElementById('tm-body');
const tmMsg = document.getElementById('tm-msg');
const tmSave = document.getElementById('tm-save');
let current = null; // { tag, type, el }
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
document.getElementById('tm-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
async function openModal(tag, el) {
tmMsg.textContent = '';
let data;
try {
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
data = await res.json();
} catch { alert('Errore di rete.'); return; }
current = { tag: data.tag, type: data.type, el };
tmTag.textContent = data.tag;
tmGuid.textContent = data.guid || '';
tmBody.innerHTML = '';
if (data.type === 'image') {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
tmBody.appendChild(inp);
const del = document.createElement('button');
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del'; del.className = 'tm-del';
tmBody.appendChild(del);
del.addEventListener('click', async () => {
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
});
} else {
const ta = document.createElement('textarea');
ta.id = 'tm-value'; ta.value = data.value;
tmBody.appendChild(ta);
const styles = data.styles || {};
const groups = (data.styleOptions || []).filter((g) => STYLE_GROUPS[g]);
if (groups.length) {
const grid = document.createElement('div');
grid.className = 'tm-styles';
let anyEstimate = false;
groups.forEach((group) => {
const explicit = styles[group]; // valore salvato nel blocco
const est = explicit ? null : effective(group, el); // altrimenti stima dall'aspetto reale
const shown = explicit || est;
if (est) anyEstimate = true;
const field = document.createElement('label');
field.className = 'tm-field';
const cap = document.createElement('span');
cap.className = 'tm-field__label';
cap.textContent = GROUP_LABELS[group] || group;
const sel = document.createElement('select');
sel.dataset.group = group;
sel.className = 'tm-style';
if (est) sel.dataset.estimated = est; // marcata come stima: non fissata al salvataggio se invariata
const none = document.createElement('option');
none.value = ''; none.textContent = '— default (tema) —';
sel.appendChild(none);
STYLE_GROUPS[group].forEach((v) => {
const o = document.createElement('option');
o.value = v; o.textContent = valLabel(group, v) + (est === v ? ' (attuale)' : '');
if (shown === v) o.selected = true;
sel.appendChild(o);
});
sel.classList.add(explicit ? 'is-set' : (est ? 'is-estimated' : ''));
sel.addEventListener('change', () => {
delete sel.dataset.estimated; // scelta manuale → diventa esplicita
sel.classList.remove('is-estimated');
sel.classList.toggle('is-set', !!sel.value);
});
field.appendChild(cap); field.appendChild(sel);
grid.appendChild(field);
});
tmBody.appendChild(grid);
if (anyEstimate) {
const note = document.createElement('p');
note.className = 'tm-note';
note.textContent = 'I valori "(attuale)" riflettono laspetto dal tema: cambiane uno per fissarlo sul blocco.';
tmBody.appendChild(note);
}
}
}
modal.hidden = false;
}
tmSave.addEventListener('click', async () => {
if (!current) return;
tmMsg.textContent = '';
if (current.type === 'image') {
const file = document.getElementById('tm-file').files[0];
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
return;
}
const value = document.getElementById('tm-value').value;
const selects = document.querySelectorAll('.tm-style');
const styles = {};
selects.forEach((s) => {
const v = s.value;
if (!v) return; // "default (tema)" → non impostato
if (s.dataset.estimated && v === s.dataset.estimated) return; // stima non modificata → non fissare
styles[s.dataset.group] = v;
});
const body = { value };
if (selects.length) body.styles = styles; // inviato sempre (anche {}) così l'azzeramento persiste
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
const saved = await r.json();
// Aggiorna in-place il contenuto nel DOM.
if (current.type === 'html') current.el.innerHTML = saved.value;
else current.el.textContent = saved.value;
closeModal();
});
document.querySelectorAll('[data-tag]').forEach((el) => {
const tag = el.dataset.tag;
const guid = el.dataset.guid || '';
const badge = document.createElement('span');
badge.className = 'tag-badge';
const label = document.createElement('span');
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
const edit = document.createElement('button');
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
edit.addEventListener('click', () => openModal(tag, el));
badge.appendChild(label); badge.appendChild(edit);
document.body.appendChild(badge);
const place = () => {
const r = el.getBoundingClientRect();
badge.style.top = window.scrollY + r.top - 16 + 'px';
badge.style.left = window.scrollX + r.left + 'px';
};
place();
addEventListener('scroll', place, { passive: true });
addEventListener('resize', place);
});
})();
</script>
)}
</body>
</html>