Files
InsanityLab-web/src/layouts/Base.astro
T
Adriano 8d593dce90 editor inline: combo mostrano il valore reale stimato dall'aspetto
Se un blocco non ha uno stile esplicito, le combo pre-selezionano il preset più
vicino all'aspetto effettivo (getComputedStyle): size dal rapporto col genitore,
color per match RGB, weight/style/align diretti. Marcato "(attuale)" con bordo
tratteggiato. Al salvataggio le stime non modificate non vengono fissate, così il
tema resta intatto; solo le scelte manuali vengono scritte. Gli styles ora sono
inviati sempre per permettere l'azzeramento a "default (tema)".

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

330 lines
19 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;
---
<!doctype html>
<html lang="it">
<head>
<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>
<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>