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:
1 parent
0e034232e9
commit
4d40adbf64
67 files changed
+17478
-5
No files matched your search
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
@@ -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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* ── 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 + ')">×</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; }
|
||||
Reference in new issue
Block a user