Vendor platform-tools: everything this site runs is now in one repo

shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
/* Social-post export engine — generalization of the pipeline proven in
fotografia.andmore.pt/views/sessions/open_spots_ig.php.
Responsibilities: render a live-preview element to a high-resolution JPG
via html2canvas, pre-baking CSS blur (html2canvas can't render
`filter: blur()`), waiting for webfonts, and bundling multi-slide exports
into a ZIP (JSZip). Pure mechanism — all template/DOM knowledge lives in
the page that uses it. */
window.SocialEngine = (function () {
'use strict';
function chunk(arr, size) {
size = Math.max(1, size | 0);
var out = [];
for (var i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
/* html2canvas does not render CSS `filter: blur(...)`. Pre-bake the blur
into the photo with Canvas 2D, swap the bg-image to that bitmap, drop
the inline filter/transform, and return a restore() callback. The blur
radius is multiplied by the export scale so the upscaled bitmap matches
what's on screen. Resolves null when there is nothing to bake. */
function bakeBlur(bgEl, photoUrl, blurAmount, scale) {
return new Promise(function (resolve) {
if (!bgEl || !photoUrl || !(blurAmount > 0)) return resolve(null);
var rect = bgEl.getBoundingClientRect();
var W = Math.max(1, Math.round(rect.width * scale));
var H = Math.max(1, Math.round(rect.height * scale));
var blurPx = blurAmount * scale;
var img = new Image();
// Photos go through the same-origin media proxy (or are local hub
// uploads), so no crossOrigin attribute is needed and the canvas
// stays untainted.
img.onload = function () {
try {
var canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
var ctx = canvas.getContext('2d');
ctx.filter = 'blur(' + blurPx + 'px) brightness(0.85)';
// Draw slightly larger than the canvas so the soft edges
// don't reveal blank pixels — same trick as the CSS scale().
var overscan = 1.1 + (blurAmount / 200);
var ratio = Math.max(W / img.width, H / img.height) * overscan;
var dw = img.width * ratio;
var dh = img.height * ratio;
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
var dataUrl = canvas.toDataURL('image/jpeg', 0.9);
var original = {
backgroundImage: bgEl.style.backgroundImage,
filter: bgEl.style.filter,
transform: bgEl.style.transform,
};
bgEl.style.backgroundImage = "url('" + dataUrl + "')";
bgEl.style.filter = 'none';
bgEl.style.transform = 'none';
resolve(function () {
bgEl.style.backgroundImage = original.backgroundImage;
bgEl.style.filter = original.filter;
bgEl.style.transform = original.transform;
});
} catch (e) {
console.warn('Blur bake failed; exporting without baked blur.', e);
resolve(null);
}
};
img.onerror = function () {
console.warn('Failed to load photo for blur bake; exporting without baked blur.');
resolve(null);
};
img.src = photoUrl;
});
}
/* Render `el` (the live preview, at its on-screen size — its CSS uses
viewport-relative clamp()s, so rendering at another width would re-flow)
and let html2canvas upscale the pixel buffer to `targetW` via `scale`.
opts.blur = { bgEl, photoUrl, amount } enables the bake step.
Returns a JPEG Blob. */
async function renderBlob(el, targetW, opts) {
opts = opts || {};
if (typeof html2canvas !== 'function') {
throw new Error('A biblioteca de exportação ainda está a carregar. Tenta novamente em segundos.');
}
var rect = el.getBoundingClientRect();
var actualW = rect.width || el.offsetWidth || targetW;
var actualH = rect.height || el.offsetHeight || targetW;
var scale = actualW > 0 ? (targetW / actualW) : 1;
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
var restore = null;
if (opts.blur) {
restore = await bakeBlur(opts.blur.bgEl, opts.blur.photoUrl, opts.blur.amount, scale);
}
try {
var canvas = await html2canvas(el, {
backgroundColor: '#000',
scale: scale,
useCORS: true,
logging: false,
width: actualW,
height: actualH,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
});
return await new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (!blob) { reject(new Error('Falha ao gerar a imagem.')); return; }
resolve(blob);
}, 'image/jpeg', 0.95);
});
} finally {
if (restore) restore();
}
}
function saveBlob(blob, fileName) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
}
/* files: [{ blob, name }]. One file downloads directly; several are
bundled into a single ZIP. */
async function saveAll(files, zipName) {
if (!files.length) return;
if (files.length === 1) { saveBlob(files[0].blob, files[0].name); return; }
if (typeof JSZip !== 'function') {
// ZIP library missing — degrade to sequential downloads.
files.forEach(function (f) { saveBlob(f.blob, f.name); });
return;
}
var zip = new JSZip();
files.forEach(function (f) { zip.file(f.name, f.blob); });
var blob = await zip.generateAsync({ type: 'blob' });
saveBlob(blob, zipName);
}
return {
chunk: chunk,
renderBlob: renderBlob,
saveBlob: saveBlob,
saveAll: saveAll,
};
})();
@@ -0,0 +1,117 @@
<?php
// ─── Publications engine (shared, per-platform) ──────────────────────────────
//
// Moved out of the hub (PLANO_SIMPLIFICACAO_HUB.md §4.3): each platform now
// generates its own social posts from LOCAL data, so there is no content pull and
// no media proxy — photos are same-origin in the app itself. Pure client-side
// render (social/engine.js → html2canvas → JPG), driven by the CFG this file
// assembles and social/page.js consumes.
//
// A platform mounts views/admin/publicacoes.php (thin stub) that builds the items
// (the generic envelope) from its own queries, sets the platform colour, and
// requires social/publications.php. This file ships the formats + template
// catalogue (was the hub's SOCIAL_FORMATS const + social_templates table) and the
// CFG builder, so the contract that page.js expects stays byte-identical.
const PUB_FORMATS = [
'1:1' => ['label' => '1:1 Quadrado', 'w' => 2160, 'h' => 2160, 'group' => 'digital'],
'4:5' => ['label' => '4:5 Retrato', 'w' => 2160, 'h' => 2700, 'group' => 'digital'],
'9:16' => ['label' => '9:16 Story', 'w' => 2160, 'h' => 3840, 'group' => 'digital'],
'16:9' => ['label' => '16:9 Horizontal', 'w' => 3840, 'h' => 2160, 'group' => 'digital'],
'card' => ['label' => 'Cartão de visita', 'w' => 1004, 'h' => 650, 'group' => 'paper'],
'a4' => ['label' => 'A4', 'w' => 2480, 'h' => 3508, 'group' => 'paper'],
'a3' => ['label' => 'A3', 'w' => 3508, 'h' => 4961, 'group' => 'paper'],
'a2' => ['label' => 'A2', 'w' => 4961, 'h' => 7016, 'group' => 'paper'],
];
const PUB_MAX_BACKGROUNDS = 20;
// Template catalogue (was the social_templates table). multi = several items per
// slide; maxItems keyed by format id with a "default" fallback.
function pub_templates(): array {
return [
['slug' => 'event-list', 'name' => 'Lista de eventos', 'multi' => true,
'maxItems' => ['default' => 4, '9:16' => 6, '4:5' => 5, '16:9' => 3, 'a4' => 8, 'a3' => 8, 'a2' => 8, 'card' => 2],
'itemTypes' => ['event', 'manual']],
['slug' => 'single-announcement', 'name' => 'Anúncio único', 'multi' => false,
'maxItems' => ['default' => 1], 'itemTypes' => ['event', 'manual']],
['slug' => 'photo-highlight', 'name' => 'Foto em destaque', 'multi' => false,
'maxItems' => ['default' => 1], 'itemTypes' => ['photo_session']],
['slug' => 'photo-grid', 'name' => 'Grelha de fotos', 'multi' => true,
'maxItems' => ['default' => 4, '9:16' => 4, '1:1' => 4], 'itemTypes' => ['photo_session']],
];
}
// Keep only the universal envelope fields the templates rely on.
function pub_normalize_items(array $items): array {
$out = [];
foreach ($items as $it) {
if (!is_array($it)) continue;
$out[] = [
'id' => (string) ($it['id'] ?? uniqid('it_')),
'type' => (string) ($it['type'] ?? 'event'),
'title' => (string) ($it['title'] ?? ''),
'subtitle' => (string) ($it['subtitle'] ?? ''),
'date_start' => (string) ($it['date_start'] ?? ''),
'date_end' => (string) ($it['date_end'] ?? ''),
'url' => (string) ($it['url'] ?? ''),
'category' => (string) ($it['category'] ?? ''),
'category_label' => (string) ($it['category_label'] ?? ''),
'fields' => is_array($it['fields'] ?? null) ? $it['fields'] : [],
];
}
return $out;
}
// Lighten (+) / darken (-) a hex colour by a percentage. Mirrors branding_shade.
function pub_shade(string $hex, int $pct): string {
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) return $hex;
$ch = function (int $c) use ($pct): int {
$delta = ($pct / 100) * ($pct < 0 ? $c : 255 - $c);
return max(0, min(255, (int) round($c + $delta)));
};
return sprintf('#%02x%02x%02x',
$ch(hexdec(substr($hex, 1, 2))),
$ch(hexdec(substr($hex, 3, 2))),
$ch(hexdec(substr($hex, 5, 2)))
);
}
// Colour presets for the picker, derived from the platform's single primary
// colour. Same {gradients, solids} shape the old branding presets produced.
function pub_presets(string $primary): array {
$primary = preg_match('/^#[0-9a-fA-F]{6}$/', $primary) ? $primary : '#5A7A62';
return [
'gradients' => [
['name' => 'Primária escura', 'css' => "linear-gradient(160deg, #101312, " . pub_shade($primary, -35) . ")"],
['name' => 'Primária', 'css' => "linear-gradient(160deg, " . pub_shade($primary, -55) . ", {$primary})"],
['name' => 'Noite', 'css' => 'linear-gradient(160deg, #0a0a0a, #2a2a2a)'],
],
'solids' => [
['name' => 'Primária', 'css' => $primary],
['name' => 'Preto', 'css' => '#0A0A0A'],
['name' => 'Branco', 'css' => '#FFFFFF'],
],
];
}
// Assemble the CFG that social/page.js consumes. $opts:
// slug, primary (#hex), items[] (envelope), photos[] (photo cfg), logo,
// watermark, category_label, preselected[]
function pub_build_config(array $opts): array {
$primary = (string) ($opts['primary'] ?? '#5A7A62');
return [
'app' => (string) ($opts['slug'] ?? ''),
'primary' => $primary,
'csrf' => function_exists('csrf_token') ? csrf_token() : '',
'formats' => PUB_FORMATS,
'templates' => pub_templates(),
'presets' => pub_presets($primary),
'logo' => ((string) ($opts['logo'] ?? '')) ?: null,
'watermark' => ((string) ($opts['watermark'] ?? '')) ?: null,
'dynamic' => pub_normalize_items($opts['items'] ?? []),
'dynamicPhoto' => array_values($opts['photos'] ?? []),
'categoryLabel' => (string) ($opts['category_label'] ?? 'Categoria'),
'presel' => array_values($opts['preselected'] ?? []),
];
}
+745
View File
@@ -0,0 +1,745 @@
'use strict';
const CFG = JSON.parse(document.getElementById('engineConfig').textContent);
const CSRF_TOKEN = CFG.csrf || '';
const DAY_NAMES = ['DOM','SEG','TER','QUA','QUI','SEX','SÁB'];
const MONTH_NAMES = ['JAN','FEV','MAR','ABR','MAI','JUN','JUL','AGO','SET','OUT','NOV','DEZ'];
/* ── State ─────────────────────────────────────────────────── */
let items = []; // [{id, title, subtitle, date}]
let nextItemId = 1;
let slides = []; // arrays of items (chunked by template max)
let currentSlide = 0;
let currentBg = null; // gradient/solid CSS or image URL
let bgIsImage = false;
let blurAmount = 0;
let overlaySource = 'none';
let overlayValue = '';
let overlayOpacity = 50;
function esc(str) {
return String(str ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ── Dynamic items (platform content endpoint) ─────────────── */
let dynSelected = new Set((CFG.presel || []).filter(id => (CFG.dynamic || []).some(d => d.id === id)));
let dynCatValue = ''; // active category filter ('' = all)
// Items visible under the current category filter (selection persists across
// filter changes — hidden-but-selected items stay in the post).
function dynVisible() {
return (CFG.dynamic || []).filter(d => !dynCatValue || (d.category || '') === dynCatValue);
}
// Build the category dropdown from the distinct categories present in the
// dynamic items (first-seen order = the platform's order). Hidden when <2.
function buildCatFilter() {
const sel = document.getElementById('dynCatFilter');
const bar = document.getElementById('dynFilterBar');
if (!sel || !bar) return;
const seen = new Map();
(CFG.dynamic || []).forEach(d => {
const k = d.category || '';
if (k && !seen.has(k)) seen.set(k, d.category_label || k);
});
if (seen.size < 2) { bar.style.display = 'none'; return; }
let html = `<option value="">Todos · ${esc(CFG.categoryLabel || 'Categoria')}</option>`;
seen.forEach((lbl, k) => { html += `<option value="${esc(k)}">${esc(lbl)}</option>`; });
sel.innerHTML = html;
sel.value = dynCatValue;
bar.style.display = '';
}
function onCatFilter(v) { dynCatValue = v; renderDynList(); }
function renderDynList() {
const list = document.getElementById('dynList');
if (!list) return;
list.innerHTML = dynVisible().map(d => {
const dp = d.date_start ? dateParts(d.date_start.slice(0, 10)) : null;
return `<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-2" style="cursor:pointer;">
<input type="checkbox" class="form-check-input flex-shrink-0 mt-0" data-dyn-id="${esc(d.id)}"
${dynSelected.has(d.id) ? 'checked' : ''}>
<div class="flex-grow-1 min-w-0">
<div class="small fw-medium text-truncate">${esc(d.title)}</div>
<div class="text-muted" style="font-size:.78rem;">
${dp ? `${dp.dayName}, ${dp.dayNum} ${dp.month}` : ''}${d.subtitle ? ` · ${esc(d.subtitle)}` : ''}
</div>
</div>
</label>`;
}).join('');
list.querySelectorAll('input[data-dyn-id]').forEach(cb => {
cb.addEventListener('change', () => {
cb.checked ? dynSelected.add(cb.dataset.dynId) : dynSelected.delete(cb.dataset.dynId);
rebuildSlides();
});
});
}
function dynToggleAll() {
// Operates on the currently-visible (filtered) items only.
const visible = dynVisible();
const everyOn = visible.length > 0 && visible.every(d => dynSelected.has(d.id));
visible.forEach(d => everyOn ? dynSelected.delete(d.id) : dynSelected.add(d.id));
document.getElementById('dynSelectAllBtn').textContent = everyOn ? 'Selecionar todos' : 'Limpar';
renderDynList();
rebuildSlides();
}
// Selected dynamic items, normalized to the same shape as manual items.
function dynamicItems() {
return (CFG.dynamic || [])
.filter(d => dynSelected.has(d.id))
.map(d => ({
id: 'dyn:' + d.id,
title: d.title,
subtitle: d.subtitle || '',
date: d.date_start ? d.date_start.slice(0, 10) : '',
}));
}
/* ── Lazy gallery browser (photo_session content, Fase 4) ──── */
// Sessions render collapsed (title + count only); the thumbnail images are
// only attached on first expand, so a platform with many galleries doesn't
// fetch hundreds of photos on page load. The same accordion is used in two
// places: the Fundo card (mode 'bg': click = use photo as background) and
// the photo-templates picker (mode 'multi': numbered multi-select).
let selectedPhotos = []; // [{url, title, subtitle, date}], in selection order
function initGalleryAccordion(containerId, mode) {
const box = document.getElementById(containerId);
if (!box) return;
box.innerHTML = (CFG.dynamicPhoto || []).map(s => `
<div class="border rounded mb-1">
<button type="button" class="ig-gal-head" data-session="${esc(s.id)}">
<span class="text-truncate">${esc(s.title)}${s.date ? ' · ' + esc(s.date) : ''}</span>
<span class="text-muted flex-shrink-0">${s.media.length} <i class="bi bi-chevron-down"></i></span>
</button>
<div class="ig-gal-body d-flex gap-2 flex-wrap p-2 d-none" data-loaded="0"></div>
</div>`).join('');
box.querySelectorAll('.ig-gal-head').forEach(head => {
head.addEventListener('click', () => {
const body = head.nextElementSibling;
const opening = body.classList.contains('d-none');
if (opening && body.dataset.loaded !== '1') {
const s = (CFG.dynamicPhoto || []).find(x => String(x.id) === head.dataset.session);
body.innerHTML = (s ? s.media : []).map(m => `
<div class="ig-photo-pick" data-session="${esc(s.id)}" data-url="${esc(m.url)}"
style="background-image:url('${esc(m.thumb)}')">
<span class="ig-photo-pick-num"></span>
</div>`).join('');
body.dataset.loaded = '1';
body.querySelectorAll('.ig-photo-pick').forEach(el => {
el.addEventListener('click', () =>
mode === 'multi' ? togglePhoto(el) : pickPhotoAsBg(el));
});
if (mode === 'multi') syncPhotoPicker();
}
body.classList.toggle('d-none', !opening);
const icon = head.querySelector('.bi');
if (icon) icon.className = opening ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
});
});
}
// Fundo card: a session photo as the post background (full-res via proxy).
function pickPhotoAsBg(el) {
clearBgActive();
el.classList.add('active');
currentBg = el.dataset.url;
bgIsImage = true;
applyStyle();
}
function togglePhoto(el) {
const url = el.dataset.url;
const idx = selectedPhotos.findIndex(p => p.url === url);
if (idx >= 0) {
selectedPhotos.splice(idx, 1);
} else {
const session = (CFG.dynamicPhoto || []).find(s => String(s.id) === el.dataset.session);
selectedPhotos.push({
url,
title: session?.title || '',
subtitle: session?.subtitle || '',
date: session?.date || '',
});
}
syncPhotoPicker();
rebuildSlides();
}
function syncPhotoPicker() {
document.querySelectorAll('#photoPicker .ig-photo-pick').forEach(el => {
const idx = selectedPhotos.findIndex(p => p.url === el.dataset.url);
el.classList.toggle('active', idx >= 0);
el.querySelector('.ig-photo-pick-num').textContent = idx >= 0 ? String(idx + 1) : '';
});
const cnt = document.getElementById('photoSelCount');
if (cnt) cnt.textContent = selectedPhotos.length + ' selecionada' + (selectedPhotos.length === 1 ? '' : 's');
}
/* ── Manual items ──────────────────────────────────────────── */
function addItem(data) {
items.push(Object.assign({ id: nextItemId++, title: '', subtitle: '', date: '' }, data || {}));
renderItemList();
rebuildSlides();
}
function removeItem(id) {
items = items.filter(i => i.id !== id);
renderItemList();
rebuildSlides();
}
function onItemField(id, field, value) {
const it = items.find(i => i.id === id);
if (it) { it[field] = value; rebuildSlides(); }
}
function renderItemList() {
const box = document.getElementById('itemList');
document.getElementById('itemsEmpty').style.display = items.length ? 'none' : '';
box.innerHTML = items.map(it => `
<div class="border rounded p-2">
<div class="d-flex gap-2 mb-1">
<input type="text" class="form-control form-control-sm" placeholder="Título *"
value="${esc(it.title)}" oninput="onItemField(${it.id},'title',this.value)">
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-2"
onclick="removeItem(${it.id})" title="Remover"><i class="bi bi-x-lg"></i></button>
</div>
<div class="d-flex gap-2">
<input type="text" class="form-control form-control-sm" placeholder="Subtítulo / local"
value="${esc(it.subtitle)}" oninput="onItemField(${it.id},'subtitle',this.value)">
<input type="date" class="form-control form-control-sm" style="max-width:150px;"
value="${esc(it.date)}" oninput="onItemField(${it.id},'date',this.value)">
</div>
</div>`).join('');
}
function dateParts(iso) {
if (!iso) return null;
const d = new Date(iso + 'T12:00:00');
if (isNaN(d)) return null;
return {
dayName: DAY_NAMES[d.getDay()],
dayNum: String(d.getDate()),
month: MONTH_NAMES[d.getMonth()],
year: String(d.getFullYear()),
};
}
/* ── Template / format ─────────────────────────────────────── */
function currentTemplate() {
const slug = document.querySelector('input[name="tplPick"]:checked')?.value;
return CFG.templates.find(t => t.slug === slug) || CFG.templates[0];
}
function currentFormatId() {
return document.querySelector('input[name="postFormat"]:checked').value;
}
// Export dimensions for the active format, honoring the paper orientation swap.
function formatDims() {
const id = currentFormatId();
const f = CFG.formats[id];
let w = f.w, h = f.h;
if (f.group === 'paper' && document.getElementById('orientSwap').checked) {
[w, h] = [h, w];
}
return { id, w, h, group: f.group };
}
function maxItemsPerSlide() {
const tpl = currentTemplate();
const id = currentFormatId();
return tpl.maxItems[id] || tpl.maxItems['default'] || 1;
}
function isPhotoTemplate(tpl) {
return (tpl.itemTypes || []).includes('photo_session');
}
function toggleCard(id, show) {
const el = document.getElementById(id);
if (el) el.style.display = show ? '' : 'none';
}
function onTemplateChange() {
const tpl = currentTemplate();
const photo = isPhotoTemplate(tpl);
const isGrid = tpl.slug === 'photo-grid';
// The heading/tagline inputs only apply to the list template; the single
// announcement and photo templates take their caption from the item itself.
const single = !tpl.multi || photo;
document.getElementById('headingWrap').style.display = single ? 'none' : '';
document.getElementById('taglineWrap').style.display = single ? 'none' : '';
toggleCard('dynContentCard', !photo);
toggleCard('manualContentCard', !photo);
toggleCard('photoCard', photo);
toggleCard('fundoCard', !isGrid);
toggleCard('estiloCard', !isGrid);
toggleCard('textoCard', !isGrid);
rebuildSlides();
}
function onFormatChange() {
const { w, h, group } = formatDims();
document.getElementById('orientWrap').style.display = group === 'paper' ? '' : 'none';
const post = document.getElementById('postPreview');
post.style.aspectRatio = w + ' / ' + h;
document.getElementById('previewWrapper').classList.toggle('is-wide', w > h);
rebuildSlides();
}
/* ── Slides (overflow pagination) ──────────────────────────── */
function rebuildSlides() {
const tpl = currentTemplate();
const usable = isPhotoTemplate(tpl)
? selectedPhotos
: dynamicItems().concat(items.filter(i => i.title.trim() !== ''));
slides = SocialEngine.chunk(usable, maxItemsPerSlide());
if (!slides.length) slides = [[]];
if (currentSlide >= slides.length) currentSlide = slides.length - 1;
renderSlideNav();
updateSlideVisuals();
}
function renderSlideNav() {
const bar = document.getElementById('slideToolbar');
const dots = document.getElementById('slideDots');
bar.style.display = slides.length > 1 ? '' : 'none';
dots.innerHTML = '';
slides.forEach((_, i) => {
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'ig-slide-dot' + (i === currentSlide ? ' active' : '');
dot.addEventListener('click', () => { currentSlide = i; renderSlideNav(); updateSlideVisuals(); });
dots.appendChild(dot);
});
}
function slidePrev() { if (currentSlide > 0) { currentSlide--; renderSlideNav(); updateSlideVisuals(); } }
function slideNext() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlideNav(); updateSlideVisuals(); } }
/* ── Slide rendering (templates) ───────────────────────────── */
function logoRowHtml() {
if (!CFG.logo || !document.getElementById('showLogo')?.checked) return '';
return `<div class="ig-logo-row"><img src="${esc(CFG.logo)}" alt=""></div>`;
}
function itemCardHtml(it) {
const dp = dateParts(it.date);
return `<div class="ig-session-card">
${dp ? `<div class="ig-date-block">
<div class="ig-date-dayname">${dp.dayName}</div>
<div class="ig-date-num">${dp.dayNum}</div>
<div class="ig-date-month">${dp.month}</div>
</div>
<div class="ig-card-divider"></div>` : ''}
<div class="ig-session-info">
<div class="ig-session-type">${esc(it.title)}</div>
${it.subtitle ? `<div class="ig-session-avail">${esc(it.subtitle)}</div>` : ''}
</div>
</div>`;
}
function renderSlide() {
const content = document.getElementById('postContent');
const tpl = currentTemplate();
const slide = slides[currentSlide] || [];
const cta = document.getElementById('ctaText').value.trim();
let html = logoRowHtml();
if (tpl.slug === 'photo-grid') {
content.innerHTML = '';
return;
}
if (tpl.slug === 'photo-highlight') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Seleciona uma foto à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else if (tpl.slug === 'single-announcement') {
const it = slide[0];
if (!it) {
html += '<p class="ig-empty-msg">Adiciona um item à esquerda</p>';
} else {
const dp = dateParts(it.date);
html += `<div class="ig-announce">
${dp ? `<p class="ig-announce-date">${dp.dayName} · ${dp.dayNum} ${dp.month} ${dp.year}</p>` : ''}
<h2 class="ig-announce-title">${esc(it.title)}</h2>
${it.subtitle ? `<p class="ig-announce-subtitle">${esc(it.subtitle)}</p>` : ''}
<div class="ig-heading-divider"></div>
</div>`;
}
} else { // event-list
const tagline = document.getElementById('tagline').value.trim();
const heading = document.getElementById('headingText').value.trim();
html += `<div class="ig-title-block">
${tagline ? `<p class="ig-tagline">${esc(tagline)}</p>` : ''}
${heading ? `<h2 class="ig-heading">${esc(heading)}</h2><div class="ig-heading-divider"></div>` : ''}
</div>
<div class="ig-sessions">
${slide.length ? slide.map(itemCardHtml).join('') : '<p class="ig-empty-msg">Adiciona itens à esquerda</p>'}
</div>`;
}
if (cta) html += `<div class="ig-cta-row"><span class="ig-cta">${esc(cta)}</span></div>`;
content.innerHTML = html;
}
/* ── Background / style ────────────────────────────────────── */
function luminance(hex) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return 0;
const n = parseInt(m[1], 16);
return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
}
// The displayed background: the user's pick, except for photo-highlight where
// the current slide's selected photo always wins.
function effectiveBg() {
const tpl = currentTemplate();
if (tpl.slug === 'photo-highlight') {
const it = (slides[currentSlide] || [])[0];
return { bg: it ? it.url : null, isImage: !!it };
}
return { bg: currentBg, isImage: bgIsImage };
}
function applyStyle() {
const post = document.getElementById('postPreview');
const bg = document.getElementById('photoBg');
const { bg: effBg, isImage: effIsImage } = effectiveBg();
if (effIsImage && effBg) {
bg.style.display = '';
bg.style.backgroundImage = "url('" + effBg.replace(/'/g, "\\'") + "')";
bg.style.filter = blurAmount > 0 ? `blur(${blurAmount}px) brightness(.85)` : '';
bg.style.transform = blurAmount > 0 ? `scale(${1 + blurAmount / 280})` : '';
post.style.background = '';
post.classList.add('is-photo');
post.classList.remove('is-light');
} else {
bg.style.display = 'none';
bg.style.backgroundImage = '';
bg.style.filter = '';
bg.style.transform = '';
post.style.background = effBg || '';
post.classList.remove('is-photo');
// Solid light colors flip the text palette + drop the dark wash.
post.classList.toggle('is-light', !!effBg && luminance(effBg) > 0.72);
}
document.getElementById('blurWrap').style.display = effIsImage ? '' : 'none';
const ov = document.getElementById('customOverlay');
if ((overlaySource === 'gradient' || overlaySource === 'solid') && overlayValue) {
ov.style.background = overlayValue;
ov.style.opacity = (overlayOpacity / 100).toString();
} else {
ov.style.background = '';
ov.style.opacity = '0';
}
post.classList.toggle('has-watermark', !!document.getElementById('showWatermark')?.checked);
const content = document.getElementById('postContent');
content.dataset.alignH = document.querySelector('input[name="alignH"]:checked').value;
content.dataset.alignV = document.querySelector('input[name="alignV"]:checked').value;
renderSlide(); // logo toggle lives inside slide markup
}
// Switches the canvas between the photo-grid layout and the normal
// background+content layout, then applies the current style.
function updateSlideVisuals() {
const tpl = currentTemplate();
const grid = document.getElementById('photoGrid');
const content = document.getElementById('postContent');
const overlay = document.querySelector('.ig-overlay');
if (tpl.slug === 'photo-grid') {
const slide = slides[currentSlide] || [];
grid.style.display = 'grid';
content.style.display = 'none';
overlay.style.display = 'none';
document.getElementById('photoBg').style.display = 'none';
document.getElementById('customOverlay').style.opacity = '0';
const cols = slide.length <= 1 ? 1 : (slide.length <= 4 ? 2 : 3);
grid.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
grid.innerHTML = slide.length
? slide.map(p => `<div class="ig-grid-cell" style="background-image:url('${esc(p.url)}')"></div>`).join('')
: '<div class="ig-grid-cell ig-grid-empty">Seleciona fotos à esquerda</div>';
return;
}
grid.style.display = 'none';
content.style.display = '';
overlay.style.display = '';
applyStyle();
}
function onBlurChange() {
blurAmount = parseInt(document.getElementById('blurRange').value, 10) || 0;
document.getElementById('blurValue').textContent = blurAmount + ' px';
applyStyle();
}
function onAlignChange() { applyStyle(); }
function onOverlayChange() {
overlaySource = document.querySelector('input[name="overlaySource"]:checked').value;
const cur = overlayValue || '';
if (overlaySource === 'gradient' && !cur.startsWith('linear-gradient') && !cur.startsWith('radial-gradient')) {
overlayValue = CFG.presets.gradients[0].css;
} else if (overlaySource === 'solid' && (cur === '' || cur.includes('gradient'))) {
overlayValue = CFG.presets.solids[0].css;
}
const hasPreset = overlaySource !== 'none';
document.getElementById('ovGradientWrap').style.display = overlaySource === 'gradient' ? '' : 'none';
document.getElementById('ovSolidWrap').style.display = overlaySource === 'solid' ? '' : 'none';
document.getElementById('ovOpacityWrap').style.display = hasPreset ? '' : 'none';
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
function onOverlayOpacityChange() {
overlayOpacity = parseInt(document.getElementById('ovOpacity').value, 10) || 0;
document.getElementById('ovOpacityValue').textContent = overlayOpacity + ' %';
applyStyle();
}
function markActive(selector, css) {
document.querySelectorAll(selector).forEach(b => b.classList.toggle('active', b.dataset.bg === css));
}
function clearBgActive() {
document.querySelectorAll('#gradientList .ig-preset, #solidList .ig-preset, .ig-img-preset, #bgGalleryList .ig-photo-pick')
.forEach(b => b.classList.remove('active'));
}
function renderPresets(containerId, presets, onPick) {
const el = document.getElementById(containerId);
el.innerHTML = presets.map(p => `
<button type="button" class="ig-preset" data-bg="${esc(p.css)}"
style="background:${esc(p.css)}" title="${esc(p.name)}"></button>`).join('');
el.querySelectorAll('.ig-preset').forEach(b => b.addEventListener('click', () => onPick(b)));
}
function pickBgPreset(btn) {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.bg;
bgIsImage = false;
applyStyle();
}
function pickOverlayPreset(btn) {
overlayValue = btn.dataset.bg;
markActive('#ovGradientList .ig-preset, #ovSolidList .ig-preset', overlayValue);
applyStyle();
}
/* Image backgrounds (gallery + local file) */
function bindImgPreset(btn) {
btn.addEventListener('click', () => {
clearBgActive();
btn.classList.add('active');
currentBg = btn.dataset.img;
bgIsImage = true;
applyStyle();
});
}
document.getElementById('imageInput').addEventListener('change', function () {
if (this.files[0]) readLocalImage(this.files[0]);
});
const dropZone = document.getElementById('dropZone');
['dragenter','dragover'].forEach(ev => dropZone.addEventListener(ev, e => {
e.preventDefault(); dropZone.classList.add('dragging');
}));
['dragleave','drop'].forEach(ev => dropZone.addEventListener(ev, () => dropZone.classList.remove('dragging')));
dropZone.addEventListener('drop', e => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) readLocalImage(file);
});
function readLocalImage(file) {
const reader = new FileReader();
reader.onload = e => {
clearBgActive();
currentBg = e.target.result;
bgIsImage = true;
applyStyle();
};
reader.readAsDataURL(file);
}
/* Gallery upload / delete */
document.getElementById('bgUploadInput').addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const icon = document.getElementById('bgUploadIcon');
icon.className = 'bi bi-arrow-clockwise ig-spin';
const fd = new FormData();
fd.append('action', 'upload_bg');
fd.append('app', CFG.app);
fd.append('bg_image', file);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
})
.then(r => r.json())
.then(data => {
if (data.status !== 'success') { alert(data.message); return; }
document.getElementById('noPresetsHint')?.remove();
const list = document.getElementById('bgPresetList');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ig-img-preset';
btn.dataset.img = data.url;
btn.dataset.id = data.id;
btn.style.backgroundImage = "url('" + data.url + "')";
btn.innerHTML = '<span class="ig-img-del" title="Eliminar" onclick="deleteBg(event,' + data.id + ')">&times;</span>';
bindImgPreset(btn);
list.appendChild(btn);
const count = document.getElementById('bgCount');
count.textContent = (parseInt(count.textContent, 10) || 0) + 1;
btn.click();
})
.catch(() => alert('Erro ao guardar a imagem.'))
.finally(() => { icon.className = 'bi bi-plus-circle'; this.value = ''; });
});
function deleteBg(ev, id) {
ev.stopPropagation();
if (!confirm('Eliminar este fundo da galeria?')) return;
const fd = new FormData();
fd.append('action', 'delete_bg');
fd.append('app', CFG.app);
fd.append('bg_id', id);
fetch(window.location.pathname + '?app=' + encodeURIComponent(CFG.app), {
method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN }, body: fd,
}).then(() => {
document.querySelector('.ig-img-preset[data-id="' + id + '"]')?.remove();
const count = document.getElementById('bgCount');
count.textContent = Math.max(0, (parseInt(count.textContent, 10) || 1) - 1);
});
}
/* ── Export ────────────────────────────────────────────────── */
function exportFileName(n) {
const tpl = currentTemplate().slug;
const date = new Date().toISOString().slice(0, 10);
return `${CFG.app}_${tpl}_${date}_${n}.jpg`;
}
function blurOpts() {
const { bg, isImage } = effectiveBg();
if (!isImage || !(blurAmount > 0)) return null;
return { bgEl: document.getElementById('photoBg'), photoUrl: bg, amount: blurAmount };
}
async function exportCurrent(btn) {
const { w } = formatDims();
btn.disabled = true;
try {
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
SocialEngine.saveBlob(blob, exportFileName(currentSlide + 1));
} catch (err) {
console.error(err);
alert('Erro ao gerar a imagem: ' + (err?.message || err));
} finally {
btn.disabled = false;
}
}
async function exportAll(btn) {
const { w } = formatDims();
const original = currentSlide;
btn.disabled = true;
try {
const files = [];
for (let i = 0; i < slides.length; i++) {
currentSlide = i;
renderSlideNav();
updateSlideVisuals();
// Give the DOM a frame to settle before capturing.
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 60)));
const blob = await SocialEngine.renderBlob(document.getElementById('postPreview'), w, { blur: blurOpts() });
files.push({ blob, name: exportFileName(i + 1) });
}
const date = new Date().toISOString().slice(0, 10);
await SocialEngine.saveAll(files, `${CFG.app}_${currentTemplate().slug}_${date}.zip`);
} catch (err) {
console.error(err);
alert('Erro ao exportar: ' + (err?.message || err));
} finally {
currentSlide = original;
renderSlideNav();
updateSlideVisuals();
btn.disabled = false;
}
}
/* ── Screenshot mode ───────────────────────────────────────── */
function enterScreenshotMode() {
const { w, h } = formatDims();
const dest = document.getElementById('screenshotPost');
const vw = window.innerWidth, vh = window.innerHeight;
if (vh * w / h <= vw) {
dest.style.height = '100dvh';
dest.style.width = `calc(100dvh * ${w} / ${h})`;
} else {
dest.style.width = '100vw';
dest.style.height = `calc(100vw * ${h} / ${w})`;
}
dest.innerHTML = '';
const clone = document.getElementById('postPreview').cloneNode(true);
clone.removeAttribute('id');
clone.style.aspectRatio = w + ' / ' + h;
dest.appendChild(clone);
const overlay = document.getElementById('screenshotOverlay');
const closeBtn = document.getElementById('screenshotCloseBtn');
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
closeBtn.style.opacity = '1';
clearTimeout(window._scTimer);
window._scTimer = setTimeout(() => { closeBtn.style.opacity = '0'; }, 2000);
}
function exitScreenshotMode() {
document.getElementById('screenshotOverlay').style.display = 'none';
document.body.style.overflow = '';
}
document.getElementById('screenshotOverlay').addEventListener('click', function (e) {
if (e.target === this) exitScreenshotMode();
});
/* ── Init ──────────────────────────────────────────────────── */
renderPresets('gradientList', CFG.presets.gradients, pickBgPreset);
renderPresets('solidList', CFG.presets.solids, pickBgPreset);
renderPresets('ovGradientList', CFG.presets.gradients, pickOverlayPreset);
renderPresets('ovSolidList', CFG.presets.solids, pickOverlayPreset);
document.querySelectorAll('#bgPresetList .ig-img-preset').forEach(bindImgPreset);
// Default background: first brand gradient.
const firstGrad = document.querySelector('#gradientList .ig-preset');
if (firstGrad) { firstGrad.classList.add('active'); currentBg = firstGrad.dataset.bg; }
buildCatFilter();
renderDynList();
initGalleryAccordion('bgGalleryList', 'bg');
initGalleryAccordion('photoPicker', 'multi');
if (!(CFG.dynamic || []).length) addItem(); // empty manual row only without dynamic source
onTemplateChange(); // sets heading visibility + builds slides
onFormatChange(); // sets aspect ratio
applyStyle();
@@ -0,0 +1,504 @@
/* ── Social-post generator (port of fotografia's ig_spots.css) ───────────────
Class names match the proven fotografia builder; the color/font tokens are
driven by the platform's app_branding row via --brand-* vars injected on
.ig-post by the generator page. Fallbacks keep posts legible for platforms
without branding configured. */
/* Local aliases resolved from branding */
.ig-post {
--sp-accent: var(--brand-accent, var(--brand-primary, #C9A96E));
--sp-on-accent: #1a1713;
--sp-font-heading: var(--brand-font-heading, 'Cormorant Garamond', serif);
--sp-font-body: var(--brand-font-body, 'Poppins', system-ui, sans-serif);
--sp-font-sub: var(--brand-font-body, 'Montserrat', sans-serif);
}
/* Dropzone */
.ig-dropzone {
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 1.25rem;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s;
}
.ig-dropzone:hover,
.ig-dropzone.dragging {
border-color: var(--am-accent, #0d6efd);
background: rgba(13,110,253,.04);
}
/* Preset swatches */
.ig-preset {
width: 36px;
height: 36px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-preset:hover { transform: scale(1.12); }
.ig-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-img-preset {
position: relative;
width: 52px;
height: 52px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
background-size: cover;
background-position: center;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-img-preset:hover { transform: scale(1.1); }
.ig-img-preset.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-img-preset .ig-img-del {
position: absolute;
top: -7px;
right: -7px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: #dc3545;
color: #fff;
font-size: 10px;
line-height: 1;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
}
.ig-img-preset:hover .ig-img-del { display: flex; }
/* Lazy gallery accordion (Fase 4: photo_session content) */
.ig-gal-head {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
background: none;
border: 0;
padding: .45rem .65rem;
font-size: .82rem;
text-align: left;
cursor: pointer;
}
.ig-gal-head:hover { background: rgba(0,0,0,.04); }
.ig-gal-body { border-top: 1px solid rgba(0,0,0,.08); }
/* Photo picker thumbnails (Fase 4: photo_session content) */
.ig-photo-pick {
position: relative;
width: 64px;
height: 64px;
border-radius: 6px;
border: 2px solid transparent;
background-size: cover;
background-position: center;
background-color: #e9ecef;
cursor: pointer;
flex-shrink: 0;
transition: transform .15s, outline .15s;
}
.ig-photo-pick:hover { transform: scale(1.06); }
.ig-photo-pick.active { border-color: #fff; outline: 2px solid var(--am-accent, #0d6efd); }
.ig-photo-pick-num {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 50%;
background: var(--am-accent, #0d6efd);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.ig-photo-pick-num:empty { display: none; }
/* Photo-grid template (Fase 4): selected photos tile the whole canvas */
.ig-post .ig-photo-grid {
position: absolute;
inset: 0;
display: none;
gap: 4px;
}
.ig-photo-grid .ig-grid-cell {
background-size: cover;
background-position: center;
background-color: rgba(255,255,255,.06);
}
.ig-photo-grid .ig-grid-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,.4);
font-family: var(--sp-font-body);
font-size: 11px;
text-align: center;
padding: 1rem;
}
/* Preview panel */
.ig-preview-sticky { position: sticky; top: 80px; }
.ig-preview-wrapper {
height: min(72vh, 580px);
display: flex;
justify-content: center;
}
/* The post canvas */
.ig-post {
position: relative;
aspect-ratio: 1 / 1;
height: 100%;
width: auto;
max-width: 100%;
background: linear-gradient(160deg, #1a1a2e, #16213e);
background-size: cover;
background-position: center;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,.35);
font-family: var(--sp-font-body);
}
/* Wide/landscape formats fit by width instead of height */
.ig-preview-wrapper.is-wide { height: auto; }
.ig-preview-wrapper.is-wide .ig-post { width: 100%; height: auto; }
.ig-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0,0,0,.15) 0%,
rgba(0,0,0,.42) 40%,
rgba(0,0,0,.72) 100%
);
}
.ig-post.is-photo .ig-overlay {
background: linear-gradient(
to bottom,
rgba(0,0,0,.05) 0%,
rgba(0,0,0,.25) 55%,
rgba(0,0,0,.78) 100%
);
}
/* Light/paper rendering (e.g. white solid backgrounds) drops the dark wash */
.ig-post.is-light .ig-overlay { background: none; }
.ig-overlay-custom {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.ig-post .ig-photo-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: filter .25s, transform .25s;
}
/* Watermark (optional, from app_branding_assets type=watermark) */
.ig-watermark {
position: absolute;
right: 4%;
bottom: 3%;
width: 14%;
opacity: .5;
pointer-events: none;
display: none;
}
.ig-post.has-watermark .ig-watermark { display: block; }
.ig-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: clamp(12px, 3.5%, 26px);
padding: clamp(16px,6%,32px) clamp(20px,8%,40px);
}
.ig-content[data-align-v="top"] { justify-content: flex-start; }
.ig-content[data-align-v="middle"] { justify-content: center; }
.ig-content[data-align-v="bottom"] { justify-content: flex-end; }
.ig-content[data-align-h="left"] { align-items: flex-start; text-align: left; }
.ig-content[data-align-h="center"] { align-items: stretch; text-align: center; }
.ig-content[data-align-h="right"] { align-items: flex-end; text-align: right; }
/* Brand logo row */
.ig-logo-row {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ig-logo-row img {
max-height: 44px;
max-width: 55%;
object-fit: contain;
}
/* Title block */
.ig-title-block { flex-shrink: 0; }
.ig-tagline {
color: rgba(255,255,255,.7);
font-family: var(--sp-font-sub);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 2.5px;
margin: 0 0 6px;
line-height: 1.4;
}
.ig-heading {
color: #fff;
font-family: var(--sp-font-heading);
font-size: clamp(22px, 6.5%, 34px);
font-weight: 400;
margin: 0 0 12px;
line-height: 1.1;
letter-spacing: .02em;
}
.ig-heading-divider {
width: 48px;
height: 1px;
background: var(--sp-accent);
display: inline-block;
}
.ig-content[data-align-h="center"] .ig-heading-divider { margin: 0 auto; display: block; }
/* Item cards (event-list) */
.ig-sessions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.ig-empty-msg {
color: rgba(255,255,255,.4);
font-size: 11px;
text-align: center;
margin: 0;
}
.ig-session-card {
background: rgba(255,255,255,.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,.2);
border-radius: 10px;
padding: 9px 12px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
}
.ig-date-block {
flex-shrink: 0;
text-align: center;
min-width: 30px;
}
.ig-date-dayname {
color: rgba(255,255,255,.55);
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
line-height: 1;
margin-bottom: 2px;
}
.ig-date-num {
color: #fff;
font-family: var(--sp-font-heading);
font-size: 26px;
font-weight: 400;
line-height: 1;
}
.ig-date-month {
color: rgba(255,255,255,.55);
font-size: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .8px;
margin-top: 2px;
}
.ig-card-divider {
width: 1px;
height: 36px;
flex-shrink: 0;
opacity: .85;
background: var(--sp-accent);
}
.ig-session-info { flex: 1; min-width: 0; }
.ig-session-type {
color: #fff;
font-family: var(--sp-font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1.2;
letter-spacing: .02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ig-session-avail {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.2px;
margin-top: 3px;
}
/* Single announcement */
.ig-announce {
flex-shrink: 0;
max-width: 100%;
}
.ig-announce-date {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
margin: 0 0 10px;
}
.ig-announce-title {
color: #fff;
font-family: var(--sp-font-heading);
font-size: clamp(26px, 9%, 44px);
font-weight: 400;
line-height: 1.08;
margin: 0 0 8px;
letter-spacing: .02em;
}
.ig-announce-subtitle {
color: rgba(255,255,255,.85);
font-family: var(--sp-font-sub);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.8px;
margin: 0 0 14px;
}
.ig-announce-extra {
color: rgba(255,255,255,.75);
font-family: var(--sp-font-body);
font-size: 12px;
line-height: 1.5;
margin: 0;
}
/* CTA row */
.ig-cta-row {
flex-shrink: 0;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,.18);
text-align: center;
}
.ig-cta {
color: var(--sp-accent);
font-family: var(--sp-font-sub);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.6px;
text-transform: uppercase;
}
/* Light/paper variant: dark text on light backgrounds */
.ig-post.is-light .ig-tagline,
.ig-post.is-light .ig-announce-extra { color: rgba(20,20,20,.6); }
.ig-post.is-light .ig-heading,
.ig-post.is-light .ig-date-num,
.ig-post.is-light .ig-session-type,
.ig-post.is-light .ig-announce-title { color: #141414; }
.ig-post.is-light .ig-announce-subtitle { color: rgba(20,20,20,.75); }
.ig-post.is-light .ig-empty-msg { color: rgba(20,20,20,.4); }
.ig-post.is-light .ig-date-dayname,
.ig-post.is-light .ig-date-month { color: rgba(20,20,20,.55); }
.ig-post.is-light .ig-session-card {
background: rgba(0,0,0,.05);
border-color: rgba(0,0,0,.12);
}
.ig-post.is-light .ig-cta-row { border-top-color: rgba(0,0,0,.15); }
/* Slide navigator */
.ig-slide-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.ig-slide-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #d0d5dc;
border: none;
padding: 0;
cursor: pointer;
transition: background .15s, transform .15s;
}
.ig-slide-dot.active { background: var(--am-accent, #0d6efd); transform: scale(1.25); }
.ig-slide-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
/* Screenshot overlay */
.ig-screenshot-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
#screenshotPost { position: relative; overflow: hidden; }
#screenshotPost .ig-post {
width: 100%;
height: 100%;
aspect-ratio: unset;
border-radius: 0;
box-shadow: none;
max-width: unset;
}
.ig-screenshot-close {
position: fixed;
top: 16px;
right: 16px;
z-index: 10000;
background: rgba(255,255,255,.18);
border: none;
color: #fff;
border-radius: 50%;
width: 44px;
height: 44px;
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition: opacity .4s;
}
@keyframes ig-spin { to { transform: rotate(360deg); } }
.ig-spin { display: inline-block; animation: ig-spin .7s linear infinite; }