bcc965655d
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>
340 lines
20 KiB
Plaintext
340 lines
20 KiB
Plaintext
---
|
||
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 l’aspetto 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>
|