Initial import of fitme3070.pt platform

Plain-PHP fleet platform with id.andmore.pt SSO. Wires shared/platform-tools
as a submodule (platform_update, backups, git/migration tools) like the other
platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 4.8 committed 2026-06-12 10:44:58 +01:00
commit e68d357177
2330 files changed
+215968

No files matched your search

+713
View File
@@ -0,0 +1,713 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_admin();
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sábado'=>'Sábado','domingo'=>'Domingo'];
$salas = ['sala1'=>'Sala 1','sala2'=>'Sala 2','crossfit'=>'CrossFit',''=>'Outro'];
// ─── AJAX: same-page JSON endpoints ───────────────────────────────────────────
// move — drag-and-drop reschedule (day + start/end; modalidade/sala unchanged)
// save — modal create (id=0) or full edit of a class
// delete — remove a class
// All validate CSRF via the X-CSRF-Token header.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['action'] ?? '', ['move', 'save', 'delete'], true)) {
header('Content-Type: application/json; charset=utf-8');
$supplied = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_csrf'] ?? '');
if (!is_string($supplied) || !hash_equals($_SESSION['csrf_token'] ?? '', $supplied)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Sessão expirada']);
exit;
}
$fail = static function (int $code, string $msg): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
};
$hhmm = '/^([01]\d|2[0-3]):[0-5]\d$/';
try {
switch ($_POST['action']) {
case 'move':
$id = (int) ($_POST['id'] ?? 0);
$dia = (string) ($_POST['dia'] ?? '');
$hi = (string) ($_POST['hora_inicio'] ?? '');
$hf = (string) ($_POST['hora_fim'] ?? '');
if ($id < 1 || !array_key_exists($dia, $dias) || !preg_match($hhmm, $hi) || !preg_match($hhmm, $hf)) {
$fail(422, 'Dados inválidos');
}
db_horario_move($pdo, $id, $dia, $hi . ':00', $hf . ':00');
echo json_encode(['ok' => true]);
break;
case 'delete':
$id = (int) ($_POST['id'] ?? 0);
if ($id < 1) $fail(422, 'Dados inválidos');
db_horario_delete($pdo, $id);
echo json_encode(['ok' => true]);
break;
case 'save':
$id = (int) ($_POST['id'] ?? 0); // 0 = criar nova aula
$mod = (int) ($_POST['id_modalidade'] ?? 0);
$dia = (string) ($_POST['dia'] ?? '');
$hi = (string) ($_POST['hora_inicio'] ?? '');
$hf = (string) ($_POST['hora_fim'] ?? '');
$local = trim((string) ($_POST['local'] ?? ''));
if ($id < 0 || !array_key_exists($dia, $dias)) $fail(422, 'Dados inválidos');
if (!preg_match($hhmm, $hi) || !preg_match($hhmm, $hf)) $fail(422, 'Horas inválidas');
if ($hf <= $hi) $fail(422, 'A hora de fim deve ser depois do início');
if (mb_strlen($local) > 50) $fail(422, 'Sala inválida');
if ($mod < 1 || !db_modalidades_get($pdo, $mod)) $fail(422, 'Modalidade inválida');
$payload = [
'id_modalidade' => $mod,
'dia_semana' => $dia,
'hora_inicio' => $hi . ':00',
'hora_fim' => $hf . ':00',
'local' => $local,
];
if ($id === 0) $id = db_horario_create($pdo, $payload);
else db_horario_update($pdo, $id, $payload);
echo json_encode(['ok' => true, 'id' => $id]);
break;
}
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Erro ao guardar']);
}
exit;
}
// ─── Build the calendar model ─────────────────────────────────────────────────
$items = db_horario_list($pdo);
$modalidades = db_modalidades_options($pdo);
$to_min = static function ($t): ?int {
if (!is_string($t) || !preg_match('/^(\d{1,2}):(\d{2})/', $t, $m)) return null;
return ((int) $m[1]) * 60 + (int) $m[2];
};
$events = [];
$minStart = 8 * 60; // default window 08:00–21:00, widened to fit the data
$maxEnd = 21 * 60;
foreach ($items as $it) {
$s = $to_min($it['hora_inicio_fmt'] ?? $it['hora_inicio'] ?? null);
if ($s === null) continue;
$e = $to_min($it['hora_fim_fmt'] ?? $it['hora_fim'] ?? null);
if ($e === null || $e <= $s) $e = $s + 60;
$minStart = min($minStart, $s);
$maxEnd = max($maxEnd, $e);
$events[] = [
'id' => (int) $it['id'],
'dia' => (string) $it['dia_semana'],
'start' => $s,
'end' => $e,
'mod' => (int) $it['id_modalidade'],
'nome' => (string) ($it['modalidade_nome'] ?? ''),
'cor' => ($it['modalidade_cor'] ?? '') ?: '#6c757d',
'sala' => (string) ($it['local'] ?? ''),
];
}
// Sala options for the filter + modal: the known set plus any legacy values in use.
$locais = $salas;
foreach ($events as $ev) {
if ($ev['sala'] !== '' && !isset($locais[$ev['sala']])) $locais[$ev['sala']] = $ev['sala'];
}
$rangeStart = max(0, intdiv($minStart, 60) * 60); // floor to the hour
$rangeEnd = (int) min(24 * 60, ceil($maxEnd / 60) * 60); // ceil to the hour
if ($rangeEnd <= $rangeStart) $rangeEnd = $rangeStart + 60;
$hourpx = 52;
$startHour = intdiv($rangeStart, 60);
$endHour = intdiv($rangeEnd, 60);
$bodyH = (int) round((($rangeEnd - $rangeStart) / 60) * $hourpx);
$byDay = array_fill_keys(array_keys($dias), []);
foreach ($events as $ev) { $byDay[$ev['dia']][] = $ev; }
$mods_js = [];
foreach ($modalidades as $m) {
$mods_js[(string) $m['m_id']] = ['nome' => $m['nome'], 'cor' => ($m['cor'] ?? '') ?: '#6c757d'];
}
$page_title = 'Horário — Calendário';
include __DIR__ . '/../../../includes/header.php';
?>
<style>
.cal-layout { display: flex; gap: 1rem; align-items: flex-start; }
.cal-layout .cal-wrap { flex: 1 1 auto; min-width: 0; }
.cal-wrap { overflow-x: auto; padding-bottom: 6px; }
.cal {
--hourpx: <?= $hourpx ?>px;
display: grid;
grid-template-columns: 56px repeat(7, minmax(116px, 1fr));
min-width: 920px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.cal-corner, .cal-dayhead {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 8px 6px;
}
.cal-dayhead {
border-left: 1px solid #e9ecef;
text-align: center;
font-weight: 600;
font-size: .8rem;
}
.cal-gutter { position: relative; }
.cal-hourlabel {
position: absolute; right: 6px; transform: translateY(-50%);
font-size: .7rem; color: #6c757d; white-space: nowrap;
}
.cal-col {
position: relative;
border-left: 1px solid #e9ecef;
background-image:
repeating-linear-gradient(to bottom, #f1f3f5 0, #f1f3f5 1px, transparent 1px, transparent calc(var(--hourpx) / 2)),
repeating-linear-gradient(to bottom, #e3e7eb 0, #e3e7eb 1px, transparent 1px, transparent var(--hourpx));
}
.cal-col.drop-hint { background-color: #eaf4ff; }
.cal-event {
position: absolute; left: 2px; width: calc(100% - 4px);
border-radius: 6px; padding: 3px 6px; overflow: hidden;
background: color-mix(in srgb, var(--c) 16%, #fff);
border: 1px solid color-mix(in srgb, var(--c) 42%, #fff);
border-left: 3px solid var(--c);
color: #1f2937; font-size: .74rem; line-height: 1.18;
box-shadow: 0 1px 2px rgba(0,0,0,.06);
cursor: grab; user-select: none; touch-action: none;
visibility: hidden; /* revealed by JS after first layout */
}
.cal.ready .cal-event { visibility: visible; }
.cal-event:hover { box-shadow: 0 2px 6px rgba(0,0,0,.14); }
.cal-event.dragging { opacity: .9; cursor: grabbing; z-index: 30; box-shadow: 0 8px 20px rgba(0,0,0,.22); }
.cal-event.cal-hidden { display: none; }
.cal-ev-title { font-weight: 600; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-ev-time { opacity: .8; font-size: .68rem; }
.cal-ev-sala { opacity: .8; font-size: .68rem; }
/* Modalidades side panel (drag source) */
.mod-panel { flex: 0 0 190px; }
.mod-panel .card-body { padding: .75rem; }
.mod-list { display: flex; flex-direction: column; gap: 6px; }
.mod-chip {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border: 1px solid #dee2e6; border-radius: 6px;
background: #fff; font-size: .85rem;
cursor: grab; user-select: none; touch-action: none;
}
.mod-chip:hover { box-shadow: 0 2px 6px rgba(0,0,0,.12); }
.mod-chip .dot { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
.mod-chip .mod-nome { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mod-chip.ghost {
position: fixed; z-index: 1090; pointer-events: none;
opacity: .92; width: 170px; box-shadow: 0 8px 20px rgba(0,0,0,.25);
cursor: grabbing;
}
@media (max-width: 991px) {
.cal-layout { flex-direction: column; }
.mod-panel { flex: none; width: 100%; }
.mod-list { flex-direction: row; flex-wrap: wrap; }
}
.cal-toast {
position: fixed; right: 18px; bottom: 18px; z-index: 1080;
padding: .6rem .9rem; border-radius: 8px; color: #fff; font-size: .85rem;
box-shadow: 0 6px 18px rgba(0,0,0,.25); opacity: 0; transform: translateY(8px);
transition: opacity .18s, transform .18s; pointer-events: none; max-width: 280px;
}
.cal-toast.show { opacity: 1; transform: translateY(0); }
.cal-toast.ok { background: #198754; }
.cal-toast.err { background: #dc3545; }
</style>
<div class="container-fluid px-0">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Horário de Aulas</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/instagram.php') ?>"><i class="bi bi-instagram me-1"></i>Instagram</a>
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-list-ul me-1"></i>Vista de lista</a>
<a class="btn btn-primary" id="btnAdd" href="<?= base_url('views/admin/horario/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Aula</a>
</div>
</div>
<p class="text-muted small mb-2"><i class="bi bi-arrows-move me-1"></i>Arraste uma aula para reagendar, ou uma modalidade do painel para criar. Clique numa aula para editar.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<i class="bi bi-funnel text-muted"></i>
<select id="fltMod" class="form-select form-select-sm" style="width:auto">
<option value="">Todas as modalidades</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= (int) $m['m_id'] ?>"><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select>
<select id="fltSala" class="form-select form-select-sm" style="width:auto">
<option value="">Todas as salas</option>
<?php foreach ($locais as $val => $label): if ($val === '') continue; ?>
<option value="<?= htmlspecialchars($val) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<span id="fltCount" class="text-muted small"></span>
</div>
<?php if (!$events): ?>
<div class="alert alert-info">Ainda não há aulas. Arraste uma modalidade do painel para o calendário para criar a primeira.</div>
<?php endif; ?>
<div class="cal-layout">
<div class="mod-panel">
<div class="card shadow-sm"><div class="card-body">
<h2 class="h6 mb-1">Modalidades</h2>
<p class="text-muted mb-2" style="font-size:.72rem">Arraste para uma hora do calendário</p>
<div class="mod-list">
<?php foreach ($modalidades as $m): ?>
<div class="mod-chip" data-mod="<?= (int) $m['m_id'] ?>">
<span class="dot" style="background:<?= htmlspecialchars(($m['cor'] ?? '') ?: '#6c757d') ?>"></span>
<span class="mod-nome"><?= htmlspecialchars($m['nome']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div></div>
</div>
<div class="cal-wrap">
<div class="cal" id="cal">
<div class="cal-corner"></div>
<?php foreach ($dias as $label): ?>
<div class="cal-dayhead"><?= htmlspecialchars($label) ?></div>
<?php endforeach; ?>
<div class="cal-gutter" style="height:<?= $bodyH ?>px">
<?php for ($h = $startHour; $h <= $endHour; $h++): ?>
<div class="cal-hourlabel" style="top:<?= ($h - $startHour) * $hourpx ?>px"><?= sprintf('%02d:00', $h) ?></div>
<?php endfor; ?>
</div>
<?php foreach ($dias as $val => $label): ?>
<div class="cal-col" data-day="<?= htmlspecialchars($val) ?>" style="height:<?= $bodyH ?>px">
<?php foreach ($byDay[$val] as $ev): ?>
<div class="cal-event"
data-id="<?= $ev['id'] ?>"
data-day="<?= htmlspecialchars($ev['dia']) ?>"
data-start="<?= $ev['start'] ?>"
data-end="<?= $ev['end'] ?>"
data-mod="<?= $ev['mod'] ?>"
data-sala="<?= htmlspecialchars($ev['sala']) ?>"
style="--c:<?= htmlspecialchars($ev['cor']) ?>"
title="<?= htmlspecialchars($ev['nome']) ?>">
<span class="cal-ev-title"><?= htmlspecialchars($ev['nome']) ?></span>
<span class="cal-ev-time"><?= sprintf('%02d:%02d', intdiv($ev['start'], 60), $ev['start'] % 60) ?>–<?= sprintf('%02d:%02d', intdiv($ev['end'], 60), $ev['end'] % 60) ?></span>
<span class="cal-ev-sala"><?= $ev['sala'] === '' ? '' : ' · ' . htmlspecialchars($locais[$ev['sala']] ?? $ev['sala']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<!-- Edit / create modal -->
<div class="modal fade" id="evModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form class="modal-content" id="evForm">
<div class="modal-header">
<h5 class="modal-title" id="evTitle">Editar Aula</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger py-2 d-none" id="evErr"></div>
<input type="hidden" id="ev-id">
<div class="row g-3">
<div class="col-12"><label class="form-label">Modalidade <span class="text-danger">*</span></label>
<select class="form-select" id="ev-mod" required>
<option value="">Selecionar…</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= (int) $m['m_id'] ?>"><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Dia <span class="text-danger">*</span></label>
<select class="form-select" id="ev-dia" required>
<?php foreach ($dias as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>"><?= $label ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Início <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="ev-ini" required></div>
<div class="col-md-4"><label class="form-label">Fim <span class="text-danger">*</span></label>
<input type="time" class="form-control" id="ev-fim" required></div>
<div class="col-12"><label class="form-label">Sala</label>
<select class="form-select" id="ev-sala">
<?php foreach ($locais as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select></div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-danger" id="evDelete"><i class="bi bi-trash me-1"></i>Apagar</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary" id="evSave">Guardar</button>
</div>
</div>
</form>
</div>
</div>
<script>
(function () {
const RANGE_START = <?= $rangeStart ?>;
const RANGE_END = <?= $rangeEnd ?>;
const HOURPX = <?= $hourpx ?>;
const PXMIN = HOURPX / 60;
const SNAP = 15; // minute granularity
const CSRF = <?= json_encode(csrf_token()) ?>;
const DAYLBL = <?= json_encode($dias, JSON_UNESCAPED_UNICODE) ?>;
const SALAS = <?= json_encode($locais, JSON_UNESCAPED_UNICODE) ?>;
const MODS = <?= json_encode($mods_js, JSON_UNESCAPED_UNICODE) ?>;
const cal = document.getElementById('cal');
const cols = [...cal.querySelectorAll('.cal-col')];
const colByDay = {};
cols.forEach(c => { colByDay[c.dataset.day] = c; });
const pad = n => String(n).padStart(2, '0');
const toHHMM = m => pad(Math.floor(m / 60)) + ':' + pad(m % 60);
const toMin = t => { const p = t.split(':'); return (+p[0]) * 60 + (+p[1]); };
const salaLabel = v => v ? (SALAS[v] || v) : '';
// Sync a card's visuals from its data-* attributes.
function refreshCard(el) {
const m = MODS[el.dataset.mod] || { nome: '', cor: '#6c757d' };
el.style.setProperty('--c', m.cor);
el.title = m.nome;
el.querySelector('.cal-ev-title').textContent = m.nome;
el.querySelector('.cal-ev-time').textContent =
toHHMM(+el.dataset.start) + '–' + toHHMM(+el.dataset.end);
const sl = salaLabel(el.dataset.sala);
el.querySelector('.cal-ev-sala').textContent = sl ? ' · ' + sl : '';
}
function makeCard(d) { // {id, day, start, end, mod, sala}
const el = document.createElement('div');
el.className = 'cal-event';
el.dataset.id = d.id; el.dataset.day = d.day;
el.dataset.start = d.start; el.dataset.end = d.end;
el.dataset.mod = d.mod; el.dataset.sala = d.sala;
el.innerHTML = '<span class="cal-ev-title"></span><span class="cal-ev-time"></span><span class="cal-ev-sala"></span>';
refreshCard(el);
return el;
}
// Position every visible event in a column, splitting overlaps side by side.
function layout(col) {
const evs = [...col.querySelectorAll('.cal-event:not(.cal-hidden)')]
.map(el => ({ el, s: +el.dataset.start, e: +el.dataset.end }))
.sort((a, b) => a.s - b.s || a.e - b.e);
let i = 0;
while (i < evs.length) {
let j = i, clusterEnd = evs[i].e;
const cluster = [evs[i]];
while (j + 1 < evs.length && evs[j + 1].s < clusterEnd) {
j++; cluster.push(evs[j]); clusterEnd = Math.max(clusterEnd, evs[j].e);
}
const laneEnd = [];
cluster.forEach(ev => {
let lane = laneEnd.findIndex(end => end <= ev.s);
if (lane < 0) lane = laneEnd.length;
laneEnd[lane] = ev.e; ev.lane = lane;
});
const lanes = laneEnd.length;
cluster.forEach(ev => {
ev.el.style.top = ((ev.s - RANGE_START) * PXMIN) + 'px';
ev.el.style.height = Math.max((ev.e - ev.s) * PXMIN - 2, 16) + 'px';
ev.el.style.left = 'calc(' + (ev.lane * 100 / lanes) + '% + 2px)';
ev.el.style.width = 'calc(' + (100 / lanes) + '% - 4px)';
});
i = j + 1;
}
}
let toastTimer = null;
function toast(msg, ok) {
let t = document.getElementById('cal-toast');
if (!t) { t = document.createElement('div'); t.id = 'cal-toast'; document.body.appendChild(t); }
t.textContent = msg;
t.className = 'cal-toast ' + (ok ? 'ok' : 'err') + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2400);
}
async function post(params) {
const res = await fetch(location.href, {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
});
const j = await res.json().catch(() => ({ ok: false }));
if (!res.ok || !j.ok) throw new Error(j.error || 'Erro ao guardar');
return j;
}
/* ── Filters ────────────────────────────────────────────────────────── */
const fltMod = document.getElementById('fltMod');
const fltSala = document.getElementById('fltSala');
const fltCount = document.getElementById('fltCount');
function applyFilter() {
const fm = fltMod.value, fs = fltSala.value;
let shown = 0;
cal.querySelectorAll('.cal-event').forEach(el => {
const show = (!fm || el.dataset.mod === fm) && (!fs || el.dataset.sala === fs);
el.classList.toggle('cal-hidden', !show);
if (show) shown++;
});
cols.forEach(layout);
const total = cal.querySelectorAll('.cal-event').length;
fltCount.textContent = (fm || fs) ? shown + ' de ' + total + ' aulas' : total + ' aulas';
}
fltMod.addEventListener('change', applyFilter);
fltSala.addEventListener('change', applyFilter);
/* ── Edit / create modal ────────────────────────────────────────────── */
const mEl = document.getElementById('evModal');
// Bootstrap's bundle is loaded by the footer, AFTER this inline script —
// resolve the Modal instance lazily, on first interaction.
let modalInst = null;
const getModal = () => modalInst || (modalInst = bootstrap.Modal.getOrCreateInstance(mEl));
const evId = document.getElementById('ev-id');
const evMod = document.getElementById('ev-mod');
const evDia = document.getElementById('ev-dia');
const evIni = document.getElementById('ev-ini');
const evFim = document.getElementById('ev-fim');
const evSala = document.getElementById('ev-sala');
const evErr = document.getElementById('evErr');
const evSave = document.getElementById('evSave');
const evDel = document.getElementById('evDelete');
const evTitle = document.getElementById('evTitle');
function showErr(msg) { evErr.textContent = msg; evErr.classList.remove('d-none'); }
function openModal(d) { // {id, mod, dia, start, end, sala}
evId.value = d.id || 0;
evMod.value = d.mod || '';
evDia.value = d.dia || 'segunda';
evIni.value = toHHMM(d.start);
evFim.value = toHHMM(d.end);
// Legacy sala values are pre-seeded into the select server-side; if one
// still isn't there (freshly typed elsewhere), add it so we don't wipe it.
if (![...evSala.options].some(o => o.value === (d.sala || ''))) {
evSala.add(new Option(d.sala, d.sala));
}
evSala.value = d.sala || '';
evTitle.textContent = d.id ? 'Editar Aula' : 'Nova Aula';
evDel.classList.toggle('d-none', !d.id);
evErr.classList.add('d-none');
getModal().show();
}
document.getElementById('evForm').addEventListener('submit', async e => {
e.preventDefault();
const id = +evId.value, mod = evMod.value, dia = evDia.value;
const hi = evIni.value, hf = evFim.value, sala = evSala.value;
if (!mod) return showErr('Escolha a modalidade.');
if (!hi || !hf || hf <= hi) return showErr('A hora de fim deve ser depois do início.');
evSave.disabled = true;
try {
const j = await post({
action: 'save', id, id_modalidade: mod, dia,
hora_inicio: hi, hora_fim: hf, local: sala,
});
let el = id ? cal.querySelector('.cal-event[data-id="' + id + '"]') : null;
if (!el) {
el = makeCard({ id: j.id, day: dia, start: toMin(hi), end: toMin(hf), mod, sala });
colByDay[dia].appendChild(el);
} else {
el.dataset.day = dia; el.dataset.start = toMin(hi); el.dataset.end = toMin(hf);
el.dataset.mod = mod; el.dataset.sala = sala;
if (el.parentElement !== colByDay[dia]) colByDay[dia].appendChild(el);
refreshCard(el);
}
applyFilter(); // also relayouts every column
getModal().hide();
toast('Aula guardada · ' + DAYLBL[dia] + ' ' + hi, true);
} catch (err) {
showErr(err.message || 'Erro ao guardar');
} finally {
evSave.disabled = false;
}
});
evDel.addEventListener('click', async () => {
const id = +evId.value;
if (!id || !confirm('Apagar esta aula?')) return;
evDel.disabled = true;
try {
await post({ action: 'delete', id });
const el = cal.querySelector('.cal-event[data-id="' + id + '"]');
if (el) { const col = el.parentElement; el.remove(); layout(col); }
applyFilter();
getModal().hide();
toast('Aula apagada', true);
} catch (err) {
showErr(err.message || 'Erro ao apagar');
} finally {
evDel.disabled = false;
}
});
document.getElementById('btnAdd').addEventListener('click', e => {
e.preventDefault();
openModal({ id: 0, mod: '', dia: 'segunda', start: 9 * 60, end: 10 * 60, sala: '' });
});
const colFromPoint = (x, y) => {
const el = document.elementFromPoint(x, y);
return el ? el.closest('.cal-col') : null;
};
/* ── Drag modalidade chip → create class at the drop slot ───────────── */
let chipDrag = null;
function chipDown(e, chip) {
e.preventDefault();
chipDrag = {
pid: e.pointerId, mod: chip.dataset.mod, chip,
startX: e.clientX, startY: e.clientY,
ghost: null, moved: false,
};
try { chip.setPointerCapture(e.pointerId); } catch (_) {}
}
function chipMove(e) {
if (!chipDrag.moved) {
if (Math.hypot(e.clientX - chipDrag.startX, e.clientY - chipDrag.startY) < 5) return;
chipDrag.moved = true;
const g = chipDrag.chip.cloneNode(true);
g.classList.add('ghost');
document.body.appendChild(g);
chipDrag.ghost = g;
}
chipDrag.ghost.style.left = (e.clientX + 10) + 'px';
chipDrag.ghost.style.top = (e.clientY + 6) + 'px';
const col = colFromPoint(e.clientX, e.clientY);
cols.forEach(c => c.classList.toggle('drop-hint', c === col));
}
function chipUp(e) {
const d = chipDrag; chipDrag = null;
try { d.chip.releasePointerCapture(e.pointerId); } catch (_) {}
if (d.ghost) d.ghost.remove();
cols.forEach(c => c.classList.remove('drop-hint'));
if (!d.moved) { // simple click → modal with defaults
openModal({ id: 0, mod: d.mod, dia: 'segunda', start: 9 * 60, end: 10 * 60, sala: '' });
return;
}
const col = colFromPoint(e.clientX, e.clientY);
if (!col) return; // dropped outside the calendar — nothing
const cr = col.getBoundingClientRect();
let mins = RANGE_START + (e.clientY - cr.top) / PXMIN;
mins = Math.round(mins / SNAP) * SNAP;
mins = Math.max(RANGE_START, Math.min(mins, RANGE_END - 60));
openModal({ id: 0, mod: d.mod, dia: col.dataset.day, start: mins, end: mins + 60, sala: '' });
}
/* ── Drag an existing class → reschedule ────────────────────────────── */
let drag = null;
function onDown(e) {
const chip = e.target.closest('.mod-chip');
if (chip && !chip.classList.contains('ghost')) { chipDown(e, chip); return; }
const el = e.target.closest('.cal-event');
if (!el || e.button !== 0 && e.pointerType === 'mouse') return;
e.preventDefault();
const r = el.getBoundingClientRect();
drag = {
el, pid: e.pointerId,
startX: e.clientX, startY: e.clientY,
grabDY: e.clientY - r.top,
dur: (+el.dataset.end) - (+el.dataset.start),
origCol: el.parentElement,
origStart: +el.dataset.start,
origDay: el.dataset.day,
cur: { mins: +el.dataset.start, day: el.dataset.day },
moved: false,
};
try { el.setPointerCapture(e.pointerId); } catch (_) {}
}
function onMove(e) {
if (chipDrag && e.pointerId === chipDrag.pid) { chipMove(e); return; }
if (!drag || e.pointerId !== drag.pid) return;
if (!drag.moved) {
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return;
drag.moved = true;
drag.el.classList.add('dragging');
drag.el.style.pointerEvents = 'none';
}
const col = colFromPoint(e.clientX, e.clientY) || drag.el.parentElement;
const cr = col.getBoundingClientRect();
let mins = RANGE_START + (e.clientY - cr.top - drag.grabDY) / PXMIN;
mins = Math.round(mins / SNAP) * SNAP;
mins = Math.max(RANGE_START, Math.min(mins, RANGE_END - drag.dur));
if (drag.el.parentElement !== col) col.appendChild(drag.el);
drag.el.style.top = ((mins - RANGE_START) * PXMIN) + 'px';
drag.el.style.left = '2px';
drag.el.style.width = 'calc(100% - 4px)';
drag.cur = { mins, day: col.dataset.day };
}
async function onUp(e) {
if (chipDrag && e.pointerId === chipDrag.pid) { chipUp(e); return; }
if (!drag || e.pointerId !== drag.pid) return;
const d = drag; drag = null;
try { d.el.releasePointerCapture(e.pointerId); } catch (_) {}
d.el.style.pointerEvents = '';
d.el.classList.remove('dragging');
if (!d.moved) { // a click, not a drag → edit modal
openModal({
id: +d.el.dataset.id, mod: d.el.dataset.mod, dia: d.el.dataset.day,
start: +d.el.dataset.start, end: +d.el.dataset.end, sala: d.el.dataset.sala,
});
return;
}
const newStart = d.cur.mins, newEnd = d.cur.mins + d.dur, day = d.cur.day;
if (day === d.origDay && newStart === d.origStart) { layout(d.el.parentElement); return; }
// Optimistic update + relayout the affected columns.
d.el.dataset.start = newStart; d.el.dataset.end = newEnd; d.el.dataset.day = day;
refreshCard(d.el);
layout(d.origCol);
if (d.el.parentElement !== d.origCol) layout(d.el.parentElement);
try {
await post({
action: 'move', id: d.el.dataset.id, dia: day,
hora_inicio: toHHMM(newStart), hora_fim: toHHMM(newEnd),
});
toast(DAYLBL[day] + ' · ' + toHHMM(newStart), true);
} catch (err) {
// Revert to the original slot.
d.el.dataset.start = d.origStart; d.el.dataset.end = d.origStart + d.dur; d.el.dataset.day = d.origDay;
refreshCard(d.el);
d.origCol.appendChild(d.el);
cols.forEach(layout);
toast(err.message || 'Não foi possível reagendar', false);
}
}
document.addEventListener('pointerdown', onDown);
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
cols.forEach(layout);
applyFilter();
cal.classList.add('ready');
})();
</script>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
+76
View File
@@ -0,0 +1,76 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_admin();
$errors = [];
$data = ['id_modalidade' => '', 'dia_semana' => '', 'hora_inicio' => '09:00', 'hora_fim' => '10:00', 'local' => 'sala1'];
$modalidades = db_modalidades_options($pdo);
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sábado'=>'Sábado','domingo'=>'Domingo'];
$salas = ['sala1'=>'Sala 1','sala2'=>'Sala 2','crossfit'=>'CrossFit',''=>'Outro'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$data = [
'id_modalidade' => (int) ($_POST['id_modalidade'] ?? 0),
'dia_semana' => trim($_POST['dia_semana'] ?? ''),
'hora_inicio' => trim($_POST['hora_inicio'] ?? ''),
'hora_fim' => trim($_POST['hora_fim'] ?? ''),
'local' => trim($_POST['local'] ?? ''),
];
if (!$data['id_modalidade']) $errors[] = 'A modalidade é obrigatória.';
if ($data['dia_semana'] === '') $errors[] = 'O dia é obrigatório.';
if ($data['hora_inicio'] === '') $errors[] = 'A hora de início é obrigatória.';
if ($data['hora_fim'] === '') $errors[] = 'A hora de fim é obrigatória.';
if (!$errors) {
$id = db_horario_create($pdo, $data);
session()->setFlashdata('success', 'Aula adicionada.');
header('Location: ' . base_url('views/admin/horario/index.php'));
exit;
}
}
$page_title = 'Adicionar Aula';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container" style="max-width:600px;">
<h1 class="h3 mb-4">Adicionar Aula</h1>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-12"><label class="form-label">Modalidade <span class="text-danger">*</span></label>
<select class="form-select" name="id_modalidade" required>
<option value="">Selecionar…</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= $m['m_id'] ?>" <?= $data['id_modalidade'] == $m['m_id'] ? 'selected' : '' ?>><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Dia <span class="text-danger">*</span></label>
<select class="form-select" name="dia_semana" required>
<option value="">Selecionar…</option>
<?php foreach ($dias as $val => $label): ?>
<option value="<?= $val ?>" <?= $data['dia_semana'] === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Início <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="hora_inicio" required value="<?= htmlspecialchars($data['hora_inicio']) ?>"></div>
<div class="col-md-4"><label class="form-label">Fim <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="hora_fim" required value="<?= htmlspecialchars($data['hora_fim']) ?>"></div>
<div class="col-12"><label class="form-label">Sala</label>
<select class="form-select" name="local">
<?php foreach ($salas as $val => $label): ?>
<option value="<?= $val ?>" <?= $data['local'] === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select></div>
</div>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
</div>
</div></form>
</div>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
+86
View File
@@ -0,0 +1,86 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_admin();
$id = (int) ($_GET['id'] ?? 0);
if (!$id) { header('Location: index.php'); exit; }
$item = db_horario_get($pdo, $id);
if (!$item) { header('Location: index.php'); exit; }
$errors = [];
$data = $item;
$modalidades = db_modalidades_options($pdo);
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sábado'=>'Sábado','domingo'=>'Domingo'];
$salas = ['sala1'=>'Sala 1','sala2'=>'Sala 2','crossfit'=>'CrossFit',''=>'Outro'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$data = [
'id_modalidade' => (int) ($_POST['id_modalidade'] ?? 0),
'dia_semana' => trim($_POST['dia_semana'] ?? ''),
'hora_inicio' => trim($_POST['hora_inicio'] ?? ''),
'hora_fim' => trim($_POST['hora_fim'] ?? ''),
'local' => trim($_POST['local'] ?? ''),
];
if (!$data['id_modalidade']) $errors[] = 'A modalidade é obrigatória.';
if ($data['dia_semana'] === '') $errors[] = 'O dia é obrigatório.';
if ($data['hora_inicio'] === '') $errors[] = 'A hora de início é obrigatória.';
if ($data['hora_fim'] === '') $errors[] = 'A hora de fim é obrigatória.';
if (!$errors) {
$changed = db_horario_update($pdo, $id, $data);
if ($changed) session()->setFlashdata('success', 'Aula atualizada.');
else session()->setFlashdata('warning', 'Nenhuma alteração.');
header('Location: ' . base_url('views/admin/horario/edit.php?id=' . $id));
exit;
}
}
$page_title = 'Editar Aula';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container" style="max-width:600px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Editar Aula</h1>
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
</div>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-12"><label class="form-label">Modalidade <span class="text-danger">*</span></label>
<select class="form-select" name="id_modalidade" required>
<option value="">Selecionar…</option>
<?php foreach ($modalidades as $m): ?>
<option value="<?= $m['m_id'] ?>" <?= ($data['id_modalidade'] ?? 0) == $m['m_id'] ? 'selected' : '' ?>><?= htmlspecialchars($m['nome']) ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Dia <span class="text-danger">*</span></label>
<select class="form-select" name="dia_semana" required>
<option value="">Selecionar…</option>
<?php foreach ($dias as $val => $label): ?>
<option value="<?= $val ?>" <?= ($data['dia_semana'] ?? '') === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select></div>
<div class="col-md-4"><label class="form-label">Início <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="hora_inicio" required value="<?= htmlspecialchars(substr($data['hora_inicio'] ?? '09:00', 0, 5)) ?>"></div>
<div class="col-md-4"><label class="form-label">Fim <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="hora_fim" required value="<?= htmlspecialchars(substr($data['hora_fim'] ?? '10:00', 0, 5)) ?>"></div>
<div class="col-12"><label class="form-label">Sala</label>
<select class="form-select" name="local">
<?php foreach ($salas as $val => $label): ?>
<option value="<?= $val ?>" <?= ($data['local'] ?? '') === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select></div>
</div>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary">Atualizar</button>
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
</div>
</div></form>
</div>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
+60
View File
@@ -0,0 +1,60 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_admin();
// Handle delete BEFORE any output
if (isset($_GET['delete']) && is_numeric($_GET['delete'])) {
csrf_check();
db_horario_delete($pdo, (int) $_GET['delete']);
session()->setFlashdata('success', 'Aula removida.');
header('Location: ' . base_url('views/admin/horario/index.php'));
exit;
}
$items = db_horario_list($pdo);
$page_title = 'Horário de Aulas';
include __DIR__ . '/../../../includes/header.php';
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sábado'=>'Sábado','domingo'=>'Domingo'];
?>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Horário de Aulas</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/instagram.php') ?>"><i class="bi bi-instagram me-1"></i>Instagram</a>
<a class="btn btn-outline-secondary" href="<?= base_url('views/admin/horario/calendar.php') ?>"><i class="bi bi-calendar3 me-1"></i>Calendário</a>
<a class="btn btn-primary" href="<?= base_url('views/admin/horario/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Aula</a>
</div>
</div>
<div class="card shadow-sm"><div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr>
<th>Modalidade</th><th>Dia</th><th>Início</th><th>Fim</th><th>Sala</th><th style="width:120px">Ações</th>
</tr></thead>
<tbody>
<?php if (!$items): ?>
<tr><td colspan="6" class="text-center text-muted py-4">Nenhuma aula definida.</td></tr>
<?php else: ?>
<?php foreach ($items as $item): ?>
<tr>
<td>
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:<?= htmlspecialchars($item['modalidade_cor']) ?>;margin-right:6px"></span>
<strong><?= htmlspecialchars($item['modalidade_nome']) ?></strong>
</td>
<td><?= htmlspecialchars($dias[$item['dia_semana']] ?? $item['dia_semana']) ?></td>
<td><?= htmlspecialchars($item['hora_inicio_fmt'] ?? substr((string) $item['hora_inicio'], 0, 5)) ?></td>
<td><?= htmlspecialchars($item['hora_fim_fmt'] ?? substr((string) $item['hora_fim'], 0, 5)) ?></td>
<td><span class="badge bg-light text-dark"><?= htmlspecialchars($item['local']) ?></span></td>
<td>
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['id'] ?>"><i class="bi bi-pencil"></i></a>
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['id'] ?>" onclick="return confirm('Remover esta aula?')"><i class="bi bi-trash"></i></a>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div></div>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';
+743
View File
@@ -0,0 +1,743 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_admin();
/* ── Background image upload (AJAX, JSON) ─────────────────────────────────────
Mirrors the fotografia "Spots IG" generator: saves a reusable background under
uploads/website/ig_backgrounds/ and returns its public URL. CSRF is checked
manually (header token) so we can answer JSON instead of csrf_check()'s HTML. */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'upload_bg') {
header('Content-Type: application/json; charset=utf-8');
$supplied = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_csrf'] ?? '');
if (!is_string($supplied) || !hash_equals($_SESSION['csrf_token'] ?? '', $supplied)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Sessão expirada. Recarregue a página.']);
exit;
}
$file = $_FILES['bg_image'] ?? null;
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
echo json_encode(['status' => 'error', 'message' => 'Falha no upload do ficheiro.']);
exit;
}
$mime = function_exists('mime_content_type') ? mime_content_type($file['tmp_name']) : ($file['type'] ?? '');
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
echo json_encode(['status' => 'error', 'message' => "Formato não suportado: $mime (aceite: JPG, PNG, WebP)."]);
exit;
}
if (!is_uploaded_file($file['tmp_name'])) {
echo json_encode(['status' => 'error', 'message' => 'Upload inválido.']);
exit;
}
$dir = __DIR__ . '/../../../uploads/website/ig_backgrounds/';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
if (!is_dir($dir) || !is_writable($dir)) {
echo json_encode(['status' => 'error', 'message' => 'Sem permissão de escrita na pasta de fundos.']);
exit;
}
$stem = preg_replace('/[^a-z0-9_-]/i', '_', pathinfo($file['name'], PATHINFO_FILENAME));
$stem = substr(trim((string) $stem, '_'), 0, 40) ?: 'fundo';
$name = $stem . '_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) {
echo json_encode(['status' => 'error', 'message' => 'Falha ao guardar o ficheiro.']);
exit;
}
img_optimize($dir . $name, IMG_MAX_GALLERY); // cap to 1920px / re-encode (functions_images.php)
echo json_encode([
'status' => 'success',
'url' => base_url('uploads/website/ig_backgrounds/' . $name),
'name' => $name,
]);
exit;
}
/* ── Delete a saved background (AJAX, JSON) ───────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_bg') {
header('Content-Type: application/json; charset=utf-8');
$supplied = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_csrf'] ?? '');
if (!is_string($supplied) || !hash_equals($_SESSION['csrf_token'] ?? '', $supplied)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Sessão expirada.']);
exit;
}
$name = basename((string) ($_POST['name'] ?? '')); // basename guards traversal
$path = __DIR__ . '/../../../uploads/website/ig_backgrounds/' . $name;
if ($name !== '' && is_file($path)) @unlink($path);
echo json_encode(['status' => 'success']);
exit;
}
/* ── Data ─────────────────────────────────────────────────────────────────── */
$items = db_horario_list($pdo);
$dias_slug = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo'];
$dias_lbl = [
'segunda' => 'Segunda', 'terça' => 'Terça', 'quarta' => 'Quarta',
'quinta' => 'Quinta', 'sexta' => 'Sexta', 'sábado' => 'Sábado', 'domingo' => 'Domingo',
];
$dias_long = [
'segunda' => 'Segunda-feira', 'terça' => 'Terça-feira', 'quarta' => 'Quarta-feira',
'quinta' => 'Quinta-feira', 'sexta' => 'Sexta-feira', 'sábado' => 'Sábado', 'domingo' => 'Domingo',
];
$salas = ['sala1' => 'Sala 1', 'sala2' => 'Sala 2', 'crossfit' => 'CrossFit'];
$to_min = static function ($t): int {
return preg_match('/^(\d{1,2}):(\d{2})/', (string) $t, $m) ? ((int) $m[1]) * 60 + (int) $m[2] : 0;
};
// Group classes by day, each a list of {hora, nome, cor, sala, icon} sorted by start time.
$by_day = array_fill_keys($dias_slug, []);
foreach ($items as $it) {
$d = (string) $it['dia_semana'];
if (!isset($by_day[$d])) continue;
$hora = $it['hora_inicio_fmt'] ?? substr((string) ($it['hora_inicio'] ?? ''), 0, 5);
$sala = (string) ($it['local'] ?? '');
$by_day[$d][] = [
'min' => $to_min($hora),
'hora' => $hora,
'nome' => (string) ($it['modalidade_nome'] ?? ''),
'cor' => ($it['modalidade_cor'] ?? '') ?: '#C2D500',
'sala' => $sala !== '' ? ($salas[$sala] ?? $sala) : '',
'icon' => ($it['modalidade_icon'] ?? '')
? base_url('uploads/modalidades/icons/' . $it['modalidade_icon'])
: '',
];
}
foreach ($by_day as &$list) {
usort($list, fn($a, $b) => $a['min'] <=> $b['min']);
}
unset($list);
// Saved backgrounds (reusable, uploaded above).
$bg_dir = __DIR__ . '/../../../uploads/website/ig_backgrounds/';
$bg_glob = is_dir($bg_dir) ? (glob($bg_dir . '*.{jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP}', GLOB_BRACE) ?: []) : [];
$bg_images = array_map(fn($p) => base_url('uploads/website/ig_backgrounds/' . basename($p)), $bg_glob);
// Default to today's weekday (PHP 'N': 1=Mon … 7=Sun → our slug order).
$today_slug = $dias_slug[((int) date('N')) - 1] ?? 'segunda';
$page_title = 'Horário — Gerador Instagram';
include __DIR__ . '/../../../includes/header.php';
?>
<style>
/* ── IG generator (scoped) ─────────────────────────────────────────────────── */
.ig-dropzone { border: 2px dashed #ced4da; border-radius: 8px; padding: 1rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; }
.ig-dropzone:hover, .ig-dropzone.dragging { border-color: var(--bo-accent); background: rgba(var(--bo-accent-rgb),.06); }
.ig-preset { width: 34px; height: 34px; border-radius: 8px; border: 2px solid transparent; cursor: pointer; flex-shrink: 0; transition: transform .15s; padding: 0; }
.ig-preset:hover { transform: scale(1.12); }
.ig-preset.active { border-color: #fff; outline: 2px solid var(--bo-accent); }
.ig-img-preset { position: relative; width: 50px; height: 50px; border-radius: 8px; border: 2px solid transparent; cursor: pointer; background-size: cover; background-position: center; flex-shrink: 0; transition: transform .15s; }
.ig-img-preset:hover { transform: scale(1.08); }
.ig-img-preset.active { border-color: #fff; outline: 2px solid var(--bo-accent); }
.ig-img-preset .ig-img-del { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #dc3545; color: #fff; border: 2px solid #fff; font-size: 10px; line-height: 1; display: none; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.ig-img-preset:hover .ig-img-del { display: flex; }
.ig-preview-sticky { position: sticky; top: 72px; }
.ig-preview-wrapper { height: min(72vh, 600px); display: flex; justify-content: center; }
.ig-post {
position: relative; aspect-ratio: 9 / 16; height: 100%; width: auto; max-width: 100%;
background: linear-gradient(160deg, #0a0a0a, #2a2a2a); background-size: cover; background-position: center;
overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,.35);
font-family: 'Roboto', 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.ig-post .ig-photo-bg { position: absolute; inset: 0; background-size: cover; background-position: center; transition: filter .25s, transform .25s; }
.ig-overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(0,0,0,.15) 0%, rgba(0,0,0,.42) 42%, rgba(0,0,0,.74) 100%); }
.ig-overlay-custom { position: absolute; inset: 0; pointer-events: none; opacity: 0; }
.ig-content { position: absolute; inset: 0; display: flex; flex-direction: column; gap: clamp(10px,3%,22px); padding: clamp(18px,7%,40px) clamp(18px,7%,38px); }
.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; }
.ig-title-block { flex-shrink: 0; }
.ig-tagline { color: rgba(255,255,255,.72); font-size: 10px; text-transform: uppercase; letter-spacing: 3px; margin: 0 0 8px; font-weight: 600; }
.ig-heading { color: #fff; font-size: clamp(26px, 8%, 46px); font-weight: 800; margin: 0 0 12px; line-height: 1; letter-spacing: -.01em; text-transform: uppercase; }
.ig-heading-divider { width: 54px; height: 3px; background: var(--bo-accent); border-radius: 2px; }
.ig-content[data-align-h="center"] .ig-heading-divider { margin-left: auto; margin-right: auto; }
.ig-content[data-align-h="right"] .ig-heading-divider { margin-left: auto; }
.ig-sessions { flex-shrink: 1; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.ig-empty-msg { color: rgba(255,255,255,.45); font-size: 12px; margin: 0; }
.ig-session-card {
background: rgba(255,255,255,.10); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,.18); border-radius: 12px; padding: 9px 12px;
display: flex; align-items: center; gap: 12px; text-align: left;
}
.ig-time-block { flex-shrink: 0; min-width: 52px; }
.ig-time-num { color: #fff; font-size: 21px; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
.ig-card-divider { width: 3px; align-self: stretch; min-height: 30px; border-radius: 2px; flex-shrink: 0; }
.ig-session-info { flex: 1; min-width: 0; }
.ig-session-type { color: #fff; font-size: 15px; font-weight: 700; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .02em; }
.ig-session-sala { color: rgba(255,255,255,.6); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.2px; margin-top: 2px; }
.ig-session-icon { width: 30px; height: 30px; border-radius: 8px; background: rgba(255,255,255,.92) center/70% no-repeat; flex-shrink: 0; }
/* Compact tiers for busy days so 9–12 classes still fit the frame. */
.ig-sessions.compact .ig-session-card { padding: 6px 10px; gap: 9px; border-radius: 10px; }
.ig-sessions.compact .ig-time-num { font-size: 17px; }
.ig-sessions.compact .ig-session-type { font-size: 12.5px; }
.ig-sessions.compact .ig-session-icon { width: 24px; height: 24px; }
.ig-sessions.micro .ig-session-card { padding: 4px 8px; gap: 7px; border-radius: 8px; }
.ig-sessions.micro .ig-time-block { min-width: 42px; }
.ig-sessions.micro .ig-time-num { font-size: 14px; }
.ig-sessions.micro .ig-session-type { font-size: 11px; }
.ig-sessions.micro .ig-session-sala { font-size: 8.5px; letter-spacing: .8px; }
.ig-sessions.micro .ig-session-icon { display: none; }
.ig-cta-row { flex-shrink: 0; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.18); }
.ig-cta { color: var(--bo-accent); font-size: 12px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; }
.ig-spin { display: inline-block; animation: ig-spin .7s linear infinite; }
@keyframes ig-spin { to { transform: rotate(360deg); } }
/* Screenshot (full-screen) mode */
.ig-screenshot-overlay { position: fixed; inset: 0; background: #000; z-index: 9999; display: flex; align-items: center; justify-content: center; }
#igScreenshotPost { position: relative; overflow: hidden; }
#igScreenshotPost .ig-post { width: 100%; height: 100%; aspect-ratio: unset; 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; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); cursor: pointer; transition: opacity .4s; }
</style>
<div class="container-fluid px-0">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<h1 class="h4 mb-1"><i class="bi bi-instagram me-1"></i> Gerador Instagram</h1>
<p class="text-muted mb-0 small">Cria uma imagem do horário de um dia para publicar nas redes sociais</p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('views/admin/horario/calendar.php') ?>"><i class="bi bi-calendar3 me-1"></i>Calendário</a>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-list-ul me-1"></i>Vista de lista</a>
</div>
</div>
<div class="row g-4 align-items-start">
<!-- ── Controls ──────────────────────────────────────────────── -->
<div class="col-12 col-lg-5">
<!-- Day -->
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-calendar-week"></i> Dia da semana</h6></div>
<div class="card-body">
<div class="btn-group w-100 flex-wrap" role="group" id="igDayGroup">
<?php foreach ($dias_slug as $i => $slug): ?>
<input type="radio" class="btn-check" name="igDay" id="igDay<?= $i ?>"
value="<?= htmlspecialchars($slug) ?>" <?= $slug === $today_slug ? 'checked' : '' ?>>
<label class="btn btn-outline-primary btn-sm" for="igDay<?= $i ?>"><?= htmlspecialchars($dias_lbl[$slug]) ?></label>
<?php endforeach; ?>
</div>
<div class="form-check form-switch mt-3 mb-0">
<input class="form-check-input" type="checkbox" id="igShowIcons" checked>
<label class="form-check-label small" for="igShowIcons">Mostrar ícones das modalidades</label>
</div>
</div>
</div>
<!-- Background -->
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-image"></i> Imagem de fundo</h6></div>
<div class="card-body">
<div id="igDropZone" class="ig-dropzone mb-3" onclick="document.getElementById('igImageInput').click()">
<i class="bi bi-cloud-upload fs-4 text-muted d-block mb-1"></i>
<p class="text-muted small mb-0">Clica ou arrasta uma foto (não guardada)</p>
</div>
<input type="file" id="igImageInput" accept="image/*" class="d-none">
<input type="file" id="igBgUploadInput" accept="image/jpeg,image/png,image/webp" class="d-none">
<p class="small text-muted mb-2 d-flex align-items-center justify-content-between">
<span>Fotos guardadas:</span>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" onclick="document.getElementById('igBgUploadInput').click()" title="Guardar nova foto">
<i class="bi bi-plus-circle" id="igBgUploadIcon"></i>
</button>
</p>
<div class="d-flex gap-2 flex-wrap mb-3" id="igBgPresetList">
<?php foreach ($bg_images as $img_url): ?>
<button type="button" class="ig-img-preset" data-img="<?= htmlspecialchars($img_url) ?>"
data-name="<?= htmlspecialchars(basename($img_url)) ?>"
style="background-image:url('<?= htmlspecialchars($img_url) ?>')"
title="<?= htmlspecialchars(basename($img_url)) ?>">
<span class="ig-img-del" title="Apagar">&times;</span>
</button>
<?php endforeach; ?>
<span class="text-muted small <?= $bg_images ? 'd-none' : '' ?>" id="igNoPresets">Sem fotos guardadas</span>
</div>
<p class="small text-muted mb-2">Gradientes:</p>
<div class="d-flex gap-2 flex-wrap mb-3" id="igGradientList"></div>
<p class="small text-muted mb-2">Cor sólida:</p>
<div class="d-flex gap-2 flex-wrap" id="igSolidList"></div>
</div>
</div>
<!-- Style -->
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-sliders"></i> Estilo</h6></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label small mb-1 text-muted d-flex justify-content-between">
<span><i class="bi bi-droplet"></i> Desfoque do fundo</span>
<span id="igBlurValue" class="text-muted">0 px</span>
</label>
<input type="range" class="form-range" id="igBlur" min="0" max="40" step="1" value="0">
<div class="form-text">Aplica-se quando o fundo é uma imagem.</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1 text-muted">Sobreposição (escurece o fundo)</label>
<div class="btn-group w-100 btn-group-sm" role="group">
<input type="radio" class="btn-check" name="igOverlay" id="igOverlayNone" value="none" checked>
<label class="btn btn-outline-secondary" for="igOverlayNone"><i class="bi bi-slash-circle"></i> Nenhuma</label>
<input type="radio" class="btn-check" name="igOverlay" id="igOverlaySolid" value="solid">
<label class="btn btn-outline-secondary" for="igOverlaySolid"><i class="bi bi-square-fill"></i> Escura</label>
</div>
</div>
<div class="mb-3" id="igOverlayOpacityWrap" style="display:none;">
<label class="form-label small mb-1 text-muted d-flex justify-content-between">
<span>Opacidade</span><span id="igOverlayOpacityValue" class="text-muted">50 %</span>
</label>
<input type="range" class="form-range" id="igOverlayOpacity" min="0" max="100" step="1" value="50">
</div>
<div class="mb-2">
<label class="form-label small mb-1 text-muted">Alinhamento horizontal</label>
<div class="btn-group w-100 btn-group-sm" role="group">
<input type="radio" class="btn-check" name="igAlignH" id="igAlignHLeft" value="left">
<label class="btn btn-outline-secondary" for="igAlignHLeft"><i class="bi bi-text-left"></i></label>
<input type="radio" class="btn-check" name="igAlignH" id="igAlignHCenter" value="center" checked>
<label class="btn btn-outline-secondary" for="igAlignHCenter"><i class="bi bi-text-center"></i></label>
<input type="radio" class="btn-check" name="igAlignH" id="igAlignHRight" value="right">
<label class="btn btn-outline-secondary" for="igAlignHRight"><i class="bi bi-text-right"></i></label>
</div>
</div>
<div>
<label class="form-label small mb-1 text-muted">Alinhamento vertical</label>
<div class="btn-group w-100 btn-group-sm" role="group">
<input type="radio" class="btn-check" name="igAlignV" id="igAlignVTop" value="top">
<label class="btn btn-outline-secondary" for="igAlignVTop"><i class="bi bi-align-top"></i></label>
<input type="radio" class="btn-check" name="igAlignV" id="igAlignVMiddle" value="middle" checked>
<label class="btn btn-outline-secondary" for="igAlignVMiddle"><i class="bi bi-align-center"></i></label>
<input type="radio" class="btn-check" name="igAlignV" id="igAlignVBottom" value="bottom">
<label class="btn btn-outline-secondary" for="igAlignVBottom"><i class="bi bi-align-bottom"></i></label>
</div>
</div>
</div>
</div>
<!-- Text -->
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-type"></i> Texto</h6></div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small mb-1">Tagline</label>
<input type="text" id="igTagline" class="form-control form-control-sm" value="Horário de aulas">
</div>
<div class="mb-2">
<label class="form-label small mb-1">Título</label>
<input type="text" id="igHeading" class="form-control form-control-sm" value="">
</div>
<div>
<label class="form-label small mb-1">Chamada à ação</label>
<input type="text" id="igCta" class="form-control form-control-sm" value="Marca o teu lugar!">
</div>
</div>
</div>
<!-- Format -->
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-aspect-ratio"></i> Formato</h6></div>
<div class="card-body">
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="igFormat" id="igFmt916" value="9/16" checked>
<label class="btn btn-outline-primary" for="igFmt916">9:16 Story</label>
<input type="radio" class="btn-check" name="igFormat" id="igFmt45" value="4/5">
<label class="btn btn-outline-primary" for="igFmt45">4:5 Retrato</label>
<input type="radio" class="btn-check" name="igFormat" id="igFmt11" value="1/1">
<label class="btn btn-outline-primary" for="igFmt11">1:1 Quadrado</label>
</div>
</div>
</div>
</div>
<!-- ── Preview ──────────────────────────────────────────────── -->
<div class="col-12 col-lg-7">
<div class="ig-preview-sticky">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-muted"><i class="bi bi-eye"></i> Pré-visualização</h6>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-dark" onclick="igDownloadJpg()"><i class="bi bi-download"></i> JPG</button>
<button class="btn btn-sm btn-dark" onclick="igEnterScreenshot()"><i class="bi bi-phone"></i> Ver no ecrã</button>
</div>
</div>
<div class="ig-preview-wrapper">
<div id="igPost" class="ig-post">
<div class="ig-photo-bg" id="igPhotoBg" style="display:none;"></div>
<div class="ig-overlay"></div>
<div class="ig-overlay-custom" id="igCustomOverlay"></div>
<div class="ig-content" data-align-h="center" data-align-v="middle">
<div class="ig-title-block">
<p id="igOutTagline" class="ig-tagline">Horário de aulas</p>
<h2 id="igOutHeading" class="ig-heading">Segunda-feira</h2>
<div class="ig-heading-divider"></div>
</div>
<div id="igOutSessions" class="ig-sessions"></div>
<div class="ig-cta-row"><span id="igOutCta" class="ig-cta">Marca o teu lugar!</span></div>
</div>
</div>
</div>
<p class="text-center text-muted small mt-2 mb-0">Descarrega em JPG (2160 px) ou abre em ecrã inteiro para tirar uma captura.</p>
</div>
</div>
</div>
</div>
<!-- Screenshot overlay -->
<div id="igScreenshotOverlay" class="ig-screenshot-overlay" style="display:none;">
<button class="ig-screenshot-close" onclick="igExitScreenshot()">&times;</button>
<div id="igScreenshotPost"></div>
</div>
<script id="igClassesData" type="application/json"><?= json_encode($by_day, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<script id="igDayLongData" type="application/json"><?= json_encode($dias_long, JSON_UNESCAPED_UNICODE) ?></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js" defer></script>
<script>
(function () {
'use strict';
const CSRF = <?= json_encode(csrf_token()) ?>;
const CLASSES = JSON.parse(document.getElementById('igClassesData').textContent || '{}');
const DAYLONG = JSON.parse(document.getElementById('igDayLongData').textContent || '{}');
const UPLOAD_URL = window.location.pathname + window.location.search;
const GRADIENTS = [
'linear-gradient(160deg,#0a0a0a,#2a2a2a)',
'linear-gradient(160deg,#1a1f00,#3a4500)',
'linear-gradient(160deg,#0b1622,#1c2b3a)',
'linear-gradient(160deg,#1f0a0a,#3a1414)',
'linear-gradient(160deg,#06130b,#0e2a16)',
'linear-gradient(160deg,#151515,#1f1f1f)',
];
const SOLIDS = ['#0a0a0a', '#151515', '#16213e', '#1f1f1f', '#C2D500', '#f5f5f5'];
/* ── State ──────────────────────────────────────────────── */
let currentBg = GRADIENTS[0];
let bgIsImage = false;
let blur = 0;
let overlaySource = 'none';
let overlayOpacity = 50;
let alignH = 'center';
let alignV = 'middle';
let headingDirty = false;
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
function currentDay() {
const r = document.querySelector('input[name="igDay"]:checked');
return r ? r.value : 'segunda';
}
/* ── Background / blur / overlay / alignment ────────────── */
function applyBackground() {
const post = $('igPost');
const bg = $('igPhotoBg');
if (bgIsImage) {
bg.style.display = '';
bg.style.backgroundImage = "url('" + currentBg.replace(/'/g, "\\'") + "')";
bg.style.filter = blur > 0 ? `blur(${blur}px) brightness(.85)` : '';
bg.style.transform = blur > 0 ? `scale(${1 + blur / 280})` : '';
post.style.background = '';
} else {
bg.style.display = 'none';
bg.style.backgroundImage = '';
bg.style.filter = '';
bg.style.transform = '';
post.style.background = currentBg;
}
const ov = $('igCustomOverlay');
if (overlaySource === 'solid') {
ov.style.background = '#000';
ov.style.opacity = (overlayOpacity / 100).toString();
} else {
ov.style.background = '';
ov.style.opacity = '0';
}
const content = post.querySelector('.ig-content');
content.dataset.alignH = alignH;
content.dataset.alignV = alignV;
}
/* ── Preset swatches ────────────────────────────────────── */
function clearBgActive() {
document.querySelectorAll('#igGradientList .ig-preset, #igSolidList .ig-preset, .ig-img-preset')
.forEach(b => b.classList.remove('active'));
}
function buildPresets() {
$('igGradientList').innerHTML = GRADIENTS.map(c =>
`<button type="button" class="ig-preset" data-bg="${c}" style="background:${c}"></button>`).join('');
$('igSolidList').innerHTML = SOLIDS.map(c =>
`<button type="button" class="ig-preset" data-bg="${c}" style="background:${c}"></button>`).join('');
document.querySelectorAll('#igGradientList .ig-preset, #igSolidList .ig-preset').forEach((b, i) => {
if (b.dataset.bg === currentBg) b.classList.add('active');
b.addEventListener('click', () => {
clearBgActive(); b.classList.add('active');
currentBg = b.dataset.bg; bgIsImage = false; applyBackground();
});
});
}
function wireImagePreset(btn) {
btn.addEventListener('click', e => {
if (e.target.closest('.ig-img-del')) return; // delete handled separately
clearBgActive(); btn.classList.add('active');
currentBg = btn.dataset.img; bgIsImage = true; applyBackground();
});
const del = btn.querySelector('.ig-img-del');
if (del) del.addEventListener('click', e => {
e.stopPropagation();
if (!confirm('Apagar esta foto guardada?')) return;
fetch(UPLOAD_URL, {
method: 'POST', headers: { 'X-CSRF-Token': CSRF },
body: new URLSearchParams({ action: 'delete_bg', name: btn.dataset.name }),
}).then(r => r.json()).then(() => {
if (btn.classList.contains('active')) { currentBg = GRADIENTS[0]; bgIsImage = false; buildPresets(); applyBackground(); }
btn.remove();
const list = $('igBgPresetList');
if (!list.querySelector('.ig-img-preset')) $('igNoPresets').classList.remove('d-none');
}).catch(() => alert('Erro ao apagar.'));
});
}
document.querySelectorAll('.ig-img-preset').forEach(wireImagePreset);
/* ── Local image (drag/drop or pick) — not saved ────────── */
function readLocalImage(file) {
const reader = new FileReader();
reader.onload = e => { clearBgActive(); currentBg = e.target.result; bgIsImage = true; applyBackground(); };
reader.readAsDataURL(file);
}
$('igImageInput').addEventListener('change', function () { if (this.files[0]) readLocalImage(this.files[0]); });
const dz = $('igDropZone');
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragging'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, () => dz.classList.remove('dragging')));
dz.addEventListener('drop', e => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith('image/')) readLocalImage(f);
});
/* ── Upload to server (saved preset) ────────────────────── */
$('igBgUploadInput').addEventListener('change', function () {
const file = this.files[0];
if (!file) return;
const icon = $('igBgUploadIcon');
icon.className = 'bi bi-arrow-clockwise ig-spin';
const fd = new FormData();
fd.append('action', 'upload_bg');
fd.append('bg_image', file);
fetch(UPLOAD_URL, { method: 'POST', headers: { 'X-CSRF-Token': CSRF }, body: fd })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') { alert(data.message || 'Erro ao guardar.'); return; }
$('igNoPresets').classList.add('d-none');
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'ig-img-preset';
btn.dataset.img = data.url; btn.dataset.name = data.name;
btn.title = data.name;
btn.style.backgroundImage = "url('" + data.url + "')";
btn.innerHTML = '<span class="ig-img-del" title="Apagar">&times;</span>';
$('igBgPresetList').appendChild(btn);
wireImagePreset(btn);
btn.click();
})
.catch(() => alert('Erro ao guardar a imagem.'))
.finally(() => { icon.className = 'bi bi-plus-circle'; this.value = ''; });
});
/* ── Style controls ─────────────────────────────────────── */
$('igBlur').addEventListener('input', function () {
blur = parseInt(this.value, 10) || 0;
$('igBlurValue').textContent = blur + ' px';
applyBackground();
});
document.querySelectorAll('input[name="igOverlay"]').forEach(r => r.addEventListener('change', function () {
overlaySource = this.value;
$('igOverlayOpacityWrap').style.display = overlaySource === 'solid' ? '' : 'none';
applyBackground();
}));
$('igOverlayOpacity').addEventListener('input', function () {
overlayOpacity = parseInt(this.value, 10) || 0;
$('igOverlayOpacityValue').textContent = overlayOpacity + ' %';
applyBackground();
});
document.querySelectorAll('input[name="igAlignH"]').forEach(r => r.addEventListener('change', function () { alignH = this.value; applyBackground(); }));
document.querySelectorAll('input[name="igAlignV"]').forEach(r => r.addEventListener('change', function () { alignV = this.value; applyBackground(); }));
/* ── Text ───────────────────────────────────────────────── */
$('igTagline').addEventListener('input', renderText);
$('igHeading').addEventListener('input', function () { headingDirty = true; renderText(); });
$('igCta').addEventListener('input', renderText);
function renderText() {
$('igOutTagline').textContent = $('igTagline').value;
$('igOutHeading').textContent = $('igHeading').value;
$('igOutCta').textContent = $('igCta').value;
}
/* ── Format ─────────────────────────────────────────────── */
document.querySelectorAll('input[name="igFormat"]').forEach(r => r.addEventListener('change', function () {
$('igPost').style.aspectRatio = this.value;
}));
/* ── Day + sessions ─────────────────────────────────────── */
$('igShowIcons').addEventListener('change', renderSessions);
document.querySelectorAll('input[name="igDay"]').forEach(r => r.addEventListener('change', () => {
if (!headingDirty) $('igHeading').value = DAYLONG[currentDay()] || '';
renderText();
renderSessions();
}));
function renderSessions() {
const day = currentDay();
const list = CLASSES[day] || [];
const box = $('igOutSessions');
const showIcons = $('igShowIcons').checked;
box.classList.remove('compact', 'micro');
if (list.length > 9) box.classList.add('micro');
else if (list.length > 6) box.classList.add('compact');
if (!list.length) {
box.innerHTML = '<p class="ig-empty-msg">Sem aulas neste dia.</p>';
return;
}
box.innerHTML = list.map(c => {
const icon = (showIcons && c.icon)
? `<div class="ig-session-icon" style="background-image:url('${esc(c.icon)}')"></div>` : '';
const sala = c.sala ? `<div class="ig-session-sala">${esc(c.sala)}</div>` : '';
return `<div class="ig-session-card">
<div class="ig-time-block"><div class="ig-time-num">${esc(c.hora)}</div></div>
<div class="ig-card-divider" style="background:${esc(c.cor)}"></div>
${icon}
<div class="ig-session-info">
<div class="ig-session-type">${esc(c.nome)}</div>
${sala}
</div>
</div>`;
}).join('');
}
/* ── Screenshot (full-screen) mode ──────────────────────── */
function activeRatio() {
const r = document.querySelector('input[name="igFormat"]:checked');
return r ? r.value : '9/16';
}
window.igEnterScreenshot = function () {
const ratio = activeRatio();
const [w, h] = ratio.split('/').map(Number);
const dest = $('igScreenshotPost');
const byH = window.innerHeight * w / h;
if (byH <= window.innerWidth) { 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 = $('igPost').cloneNode(true);
clone.removeAttribute('id');
clone.style.aspectRatio = ratio;
dest.appendChild(clone);
$('igScreenshotOverlay').style.display = 'flex';
document.body.style.overflow = 'hidden';
};
window.igExitScreenshot = function () {
$('igScreenshotOverlay').style.display = 'none';
document.body.style.overflow = '';
};
$('igScreenshotOverlay').addEventListener('click', function (e) { if (e.target === this) window.igExitScreenshot(); });
/* ── JPG export (html2canvas) ───────────────────────────────
html2canvas can't render CSS `filter: blur()`, so for image
backgrounds with blur we pre-bake the blur into a bitmap via
Canvas 2D, swap it in, render, then restore. */
function bakeBlur(scale) {
return new Promise(resolve => {
if (!bgIsImage || !(blur > 0)) return resolve(null);
const bgEl = $('igPhotoBg');
const rect = bgEl.getBoundingClientRect();
const W = Math.max(1, Math.round(rect.width * scale));
const H = Math.max(1, Math.round(rect.height * scale));
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.filter = `blur(${blur * scale}px) brightness(0.85)`;
const overscan = 1.1 + (blur / 200);
const ratio = Math.max(W / img.width, H / img.height) * overscan;
const dw = img.width * ratio, dh = img.height * ratio;
ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
const orig = { 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(() => { bgEl.style.backgroundImage = orig.backgroundImage; bgEl.style.filter = orig.filter; bgEl.style.transform = orig.transform; });
} catch (e) { console.warn('blur bake failed', e); resolve(null); }
};
img.onerror = () => resolve(null);
img.src = currentBg;
});
}
window.igDownloadJpg = async function () {
if (typeof html2canvas !== 'function') {
alert('A biblioteca de exportação ainda está a carregar. Tenta novamente em segundos.');
return;
}
const source = $('igPost');
const TARGET_W = 2160; // 2× IG render width
const rect = source.getBoundingClientRect();
const actualW = rect.width || source.offsetWidth || TARGET_W;
const actualH = rect.height || source.offsetHeight || TARGET_W;
const scale = actualW > 0 ? TARGET_W / actualW : 1;
let restore = null;
try {
if (document.fonts && document.fonts.ready) await document.fonts.ready;
restore = await bakeBlur(scale);
const canvas = await html2canvas(source, {
backgroundColor: '#000', scale, useCORS: true, logging: false,
width: actualW, height: actualH,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
});
await new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) { reject(new Error('Falha ao gerar a imagem.')); return; }
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `horario-${currentDay()}-${new Date().toISOString().slice(0,10)}.jpg`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1500);
resolve();
}, 'image/jpeg', 0.95);
});
} catch (err) {
console.error(err);
alert('Erro ao gerar a imagem: ' + (err && err.message ? err.message : err));
} finally {
if (restore) restore();
}
};
/* ── Init ───────────────────────────────────────────────── */
buildPresets();
$('igHeading').value = DAYLONG[currentDay()] || '';
renderText();
renderSessions();
applyBackground();
})();
</script>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>