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:
commit
e68d357177
2330 files changed
+215968
No files matched your search
@@ -0,0 +1,96 @@
|
||||
<?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();
|
||||
|
||||
// Counts for the dashboard widgets.
|
||||
$counts = [
|
||||
'clientes' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'client'")->fetchColumn(),
|
||||
'equipa' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role IN ('admin','staff')")->fetchColumn(),
|
||||
];
|
||||
|
||||
$page_title = 'Painel';
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
|
||||
<h1 class="h3 mb-0">Olá, <?= htmlspecialchars($user['name']) ?></h1>
|
||||
<span class="text-muted">Painel de administração</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Clientes</div>
|
||||
<div class="display-6"><?= $counts['clientes'] ?></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Equipa</div>
|
||||
<div class="display-6"><?= $counts['equipa'] ?></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4"><div class="card-body">
|
||||
<h2 class="h5">Gestão de Conteúdos</h2>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/team/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-people-fill fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Equipa</strong>
|
||||
<p class="text-muted small mb-0">Gerir treinadores e staff</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/modalidades/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-grid-3x3-gap-fill fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Modalidades</strong>
|
||||
<p class="text-muted small mb-0">Gerir aulas e categorias</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/galerias/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-images fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Galerias</strong>
|
||||
<p class="text-muted small mb-0">Gerir galerias de fotos</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/work_hours/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-clock-fill fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Horário de Funcionamento</strong>
|
||||
<p class="text-muted small mb-0">Horas de abertura</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/horario/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-calendar-week-fill fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Horário de Aulas</strong>
|
||||
<p class="text-muted small mb-0">Aulas semanais</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<h2 class="h5">Acessos rápidos</h2>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<a class="btn btn-outline-primary" href="<?= base_url('views/user/index.php') ?>"><i class="bi bi-people me-1"></i>Utilizadores</a>
|
||||
<a class="btn btn-outline-secondary" href="<?= base_url('views/sysadmin/system_settings.php') ?>"><i class="bi bi-sliders me-1"></i>Definições</a>
|
||||
<a class="btn btn-outline-secondary" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Site público</a>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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 = [];
|
||||
$name = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($name === '') $errors[] = 'O nome é obrigatório.';
|
||||
if (!$errors) {
|
||||
$id = db_gallery_create($pdo, $name);
|
||||
session()->setFlashdata('success', 'Galeria criada.');
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Nova Galeria';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:500px;">
|
||||
<h1 class="h3 mb-4">Nova Galeria</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="mb-3"><label class="form-label">Nome da galeria <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="name" required value="<?= htmlspecialchars($name) ?>" placeholder="ex: Instalações">
|
||||
</div>
|
||||
<div class="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'; ?>
|
||||
@@ -0,0 +1,127 @@
|
||||
<?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; }
|
||||
|
||||
$gallery = db_gallery_get($pdo, $id);
|
||||
if (!$gallery) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$name = $gallery['name'];
|
||||
$images = db_gallery_images($pdo, $id);
|
||||
|
||||
// Rename gallery
|
||||
if (isset($_POST['rename'])) {
|
||||
csrf_check();
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($name === '') $errors[] = 'O nome é obrigatório.';
|
||||
elseif (db_gallery_update($pdo, $id, $name)) {
|
||||
session()->setFlashdata('success', 'Galeria renomeada.');
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload image
|
||||
if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) {
|
||||
csrf_check();
|
||||
$result = db_gallery_image_upload($pdo, $id, $_FILES['image']);
|
||||
if ($result) session()->setFlashdata('success', 'Imagem carregada.');
|
||||
else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.');
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optimize existing images (resize/compress to the gallery cap)
|
||||
if (isset($_POST['optimize'])) {
|
||||
csrf_check();
|
||||
$stats = img_optimize_dir(__DIR__ . '/../../../uploads/gallery/' . $id . '/', IMG_MAX_GALLERY);
|
||||
session()->setFlashdata($stats['failed'] ? 'warning' : 'success', img_stats_message($stats));
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delete image
|
||||
if (isset($_GET['del_img']) && is_numeric($_GET['del_img'])) {
|
||||
csrf_check();
|
||||
db_gallery_image_delete($pdo, (int) $_GET['del_img'], $id);
|
||||
session()->setFlashdata('success', 'Imagem removida.');
|
||||
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
|
||||
$page_title = 'Gerir Galeria';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:900px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0"><?= htmlspecialchars($gallery['name']) ?></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; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<h2 class="h6 mb-3">Renomear</h2>
|
||||
<form method="POST">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-3">
|
||||
<input class="form-control" name="name" value="<?= htmlspecialchars($name) ?>">
|
||||
</div>
|
||||
<button type="submit" name="rename" class="btn btn-primary btn-sm">Renomear</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card shadow-sm mt-3"><div class="card-body">
|
||||
<h2 class="h6 mb-3">Adicionar imagem</h2>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-3">
|
||||
<input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.gif,.webp">
|
||||
<div class="form-text">JPG, PNG, GIF ou WebP.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h6 mb-0">Imagens (<?= count($images) ?>)</h2>
|
||||
<?php if ($images): ?>
|
||||
<form method="POST" class="mb-0">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" name="optimize" value="1" class="btn btn-sm btn-outline-success"
|
||||
title="Redimensiona e comprime as imagens desta galeria para o máximo de <?= IMG_MAX_GALLERY ?>px"
|
||||
onclick="return confirm('Otimizar todas as imagens desta galeria (máx. <?= IMG_MAX_GALLERY ?>px)? Os ficheiros originais serão substituídos.')">
|
||||
<i class="bi bi-arrows-angle-contract me-1"></i>Otimizar imagens
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!$images): ?>
|
||||
<p class="text-muted">Nenhuma imagem nesta galeria.</p>
|
||||
<?php else: ?>
|
||||
<div class="row g-2">
|
||||
<?php foreach ($images as $img): ?>
|
||||
<div class="col-6 col-lg-4 position-relative">
|
||||
<img src="<?= base_url('uploads/gallery/' . $id . '/' . $img['i_file_name']) ?>"
|
||||
alt="" class="img-thumbnail w-100" style="height:140px;object-fit:cover;" loading="lazy">
|
||||
<a class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 rounded-circle"
|
||||
href="?id=<?= $id ?>&del_img=<?= $img['i_id'] ?>"
|
||||
onclick="return confirm('Remover esta imagem?')"
|
||||
style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:12px">×</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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_gallery_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Galeria apagada.');
|
||||
header('Location: ' . base_url('views/admin/galerias/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optimize every image in every gallery (resize/compress to the gallery cap)
|
||||
if (isset($_POST['optimize_all'])) {
|
||||
csrf_check();
|
||||
$stats = ['resized' => 0, 'skipped' => 0, 'failed' => 0, 'saved' => 0];
|
||||
foreach (glob(__DIR__ . '/../../../uploads/gallery/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
foreach (img_optimize_dir($dir . '/', IMG_MAX_GALLERY) as $k => $v) $stats[$k] += $v;
|
||||
}
|
||||
session()->setFlashdata($stats['failed'] ? 'warning' : 'success', img_stats_message($stats));
|
||||
header('Location: ' . base_url('views/admin/galerias/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$galleries = db_gallery_list($pdo);
|
||||
$page_title = 'Galerias';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Galerias</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" class="mb-0">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" name="optimize_all" value="1" class="btn btn-outline-success"
|
||||
title="Redimensiona e comprime as imagens de todas as galerias para o máximo de <?= IMG_MAX_GALLERY ?>px"
|
||||
onclick="return confirm('Otimizar as imagens de todas as galerias (máx. <?= IMG_MAX_GALLERY ?>px)? Os ficheiros originais serão substituídos.')">
|
||||
<i class="bi bi-arrows-angle-contract me-1"></i>Otimizar imagens
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn-primary" href="<?= base_url('views/admin/galerias/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Nova Galeria</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>Nome</th><th style="width:120px">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$galleries): ?>
|
||||
<tr><td colspan="2" class="text-center text-muted py-4">Nenhuma galeria.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($galleries as $g): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($g['name']) ?></strong></td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $g['id'] ?>"><i class="bi bi-pencil"></i> Gerir</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $g['id'] ?>" onclick="return confirm('Apagar esta galeria e todas as suas imagens?')"><i class="bi bi-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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';
|
||||
@@ -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">×</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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
|
||||
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">×</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'; ?>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?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 = ['nome' => '', 'cor' => '#808080', 'icon' => '', 'descricao' => '', 'item_order' => 0];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'nome' => trim($_POST['nome'] ?? ''),
|
||||
'cor' => trim($_POST['cor'] ?? '#808080'),
|
||||
'icon' => trim($_POST['icon'] ?? ''),
|
||||
'descricao' => trim($_POST['descricao'] ?? ''),
|
||||
'item_order' => (int) ($_POST['item_order'] ?? 0),
|
||||
];
|
||||
if ($data['nome'] === '') $errors[] = 'O nome é obrigatório.';
|
||||
|
||||
// An uploaded image, if any, wins over the typed icon filename.
|
||||
if (!$errors) {
|
||||
$icon_err = null;
|
||||
$uploaded = modalidades_icon_upload($_FILES['icon_file'] ?? [], $icon_err);
|
||||
if ($icon_err) $errors[] = $icon_err;
|
||||
elseif ($uploaded) $data['icon'] = $uploaded;
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
$id = db_modalidades_create($pdo, $data);
|
||||
session()->setFlashdata('success', 'Modalidade criada.');
|
||||
header('Location: ' . base_url('views/admin/modalidades/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$icons = db_flaticon_list($pdo);
|
||||
$page_title = 'Adicionar Modalidade';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:700px;">
|
||||
<h1 class="h3 mb-4">Adicionar Modalidade</h1>
|
||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label class="form-label">Nome <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="nome" required value="<?= htmlspecialchars($data['nome']) ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Cor</label>
|
||||
<input type="color" class="form-control form-control-color" name="cor" value="<?= htmlspecialchars($data['cor']) ?>"></div>
|
||||
<div class="col-md-6"><label class="form-label">Ícone (nome do ficheiro)</label>
|
||||
<input class="form-control" name="icon" placeholder="ex: crossfit.png" value="<?= htmlspecialchars($data['icon']) ?>">
|
||||
<div class="form-text">Escolha um da biblioteca abaixo, ou carregue um novo.</div></div>
|
||||
<div class="col-md-6"><label class="form-label">Carregar imagem</label>
|
||||
<input type="file" class="form-control" name="icon_file" accept=".png,.jpg,.jpeg,.gif,.webp,.svg">
|
||||
<div class="form-text">Substitui o nome acima. Máx. 2 MB.</div></div>
|
||||
<div class="col-md-3"><label class="form-label">Ordem</label>
|
||||
<input type="number" class="form-control" name="item_order" value="<?= (int) $data['item_order'] ?>" min="0"></div>
|
||||
<div class="col-12"><label class="form-label">Descrição</label>
|
||||
<textarea class="form-control" name="descricao" rows="3"><?= htmlspecialchars($data['descricao']) ?></textarea></div>
|
||||
</div>
|
||||
<?php if ($icons): ?>
|
||||
<div class="mt-3"><label class="form-label small text-muted">Ícones disponíveis</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<?php foreach ($icons as $icon): ?>
|
||||
<div class="text-center" style="width:90px">
|
||||
<img src="<?= base_url('uploads/modalidades/icons/' . $icon['name']) ?>" alt="" style="height:28px" onerror="this.style.display='none'">
|
||||
<div class="small text-muted text-truncate"><?= htmlspecialchars($icon['name']) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<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'; ?>
|
||||
@@ -0,0 +1,112 @@
|
||||
<?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; }
|
||||
|
||||
if (isset($_GET['restore'])) {
|
||||
csrf_check();
|
||||
db_modalidades_restore($pdo, $id);
|
||||
session()->setFlashdata('success', 'Modalidade restaurada.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$item = db_modalidades_get($pdo, $id);
|
||||
if (!$item) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$data = $item;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'nome' => trim($_POST['nome'] ?? ''),
|
||||
'cor' => trim($_POST['cor'] ?? '#808080'),
|
||||
'icon' => trim($_POST['icon'] ?? ''),
|
||||
'descricao' => trim($_POST['descricao'] ?? ''),
|
||||
'item_order' => (int) ($_POST['item_order'] ?? 0),
|
||||
];
|
||||
if ($data['nome'] === '') $errors[] = 'O nome é obrigatório.';
|
||||
|
||||
// Image: a new upload replaces the current icon; otherwise an explicit
|
||||
// "remover" clears it. Either way the old custom file is cleaned up (the
|
||||
// helper leaves shared-library icons alone).
|
||||
$uploaded = null;
|
||||
if (!$errors) {
|
||||
$icon_err = null;
|
||||
$uploaded = modalidades_icon_upload($_FILES['icon_file'] ?? [], $icon_err);
|
||||
if ($icon_err) $errors[] = $icon_err;
|
||||
}
|
||||
if (!$errors) {
|
||||
if ($uploaded) {
|
||||
modalidades_icon_remove($item['icon']);
|
||||
$data['icon'] = $uploaded;
|
||||
} elseif (!empty($_POST['remove_icon'])) {
|
||||
modalidades_icon_remove($item['icon']);
|
||||
$data['icon'] = '';
|
||||
}
|
||||
$changed = db_modalidades_update($pdo, $id, $data);
|
||||
if ($changed) session()->setFlashdata('success', 'Modalidade atualizada.');
|
||||
else session()->setFlashdata('warning', 'Nenhuma alteração.');
|
||||
header('Location: ' . base_url('views/admin/modalidades/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$icons = db_flaticon_list($pdo);
|
||||
$page_title = 'Editar Modalidade';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:700px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Editar Modalidade</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" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label class="form-label">Nome <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="nome" required value="<?= htmlspecialchars($data['nome'] ?? '') ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Cor</label>
|
||||
<input type="color" class="form-control form-control-color" name="cor" value="<?= htmlspecialchars($data['cor'] ?? '#808080') ?>"></div>
|
||||
<div class="col-md-6"><label class="form-label">Ícone</label>
|
||||
<input class="form-control" name="icon" value="<?= htmlspecialchars($data['icon'] ?? '') ?>">
|
||||
<?php if (!empty($item['icon'])): ?><div class="form-text"><img src="<?= base_url('uploads/modalidades/icons/' . $item['icon']) ?>" style="height:28px;"> atual</div><?php endif; ?></div>
|
||||
<div class="col-md-6"><label class="form-label">Carregar imagem</label>
|
||||
<input type="file" class="form-control" name="icon_file" accept=".png,.jpg,.jpeg,.gif,.webp,.svg">
|
||||
<div class="form-text">Substitui o ícone atual. Máx. 2 MB.</div>
|
||||
<?php if (!empty($item['icon'])): ?>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="remove_icon" id="remove_icon" value="1">
|
||||
<label class="form-check-label small" for="remove_icon">Remover imagem atual</label>
|
||||
</div>
|
||||
<?php endif; ?></div>
|
||||
<div class="col-md-3"><label class="form-label">Ordem</label>
|
||||
<input type="number" class="form-control" name="item_order" value="<?= (int) ($data['item_order'] ?? 0) ?>" min="0"></div>
|
||||
<div class="col-12"><label class="form-label">Descrição</label>
|
||||
<textarea class="form-control" name="descricao" rows="3"><?= htmlspecialchars($data['descricao'] ?? '') ?></textarea></div>
|
||||
</div>
|
||||
<?php if ($icons): ?>
|
||||
<div class="mt-3"><label class="form-label small text-muted">Ícones disponíveis</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<?php foreach ($icons as $icon): ?>
|
||||
<div class="text-center" style="width:90px">
|
||||
<img src="<?= base_url('uploads/modalidades/icons/' . $icon['name']) ?>" alt="" style="height:28px" onerror="this.style.display='none'">
|
||||
<div class="small text-muted text-truncate"><?= htmlspecialchars($icon['name']) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<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'; ?>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?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_modalidades_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Modalidade apagada.');
|
||||
header('Location: ' . base_url('views/admin/modalidades/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optimize existing icons (resize/compress to the modalidades cap).
|
||||
// Covers the whole icons dir, shared flaticon library included — resizing in
|
||||
// place keeps the filenames, so nothing the picker or the site references breaks.
|
||||
if (isset($_POST['optimize'])) {
|
||||
csrf_check();
|
||||
$stats = img_optimize_dir(modalidades_icon_dir(), IMG_MAX_MODALIDADE);
|
||||
session()->setFlashdata($stats['failed'] ? 'warning' : 'success', img_stats_message($stats));
|
||||
header('Location: ' . base_url('views/admin/modalidades/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$items = db_modalidades_list($pdo);
|
||||
$page_title = 'Modalidades';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Modalidades</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" class="mb-0">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" name="optimize" value="1" class="btn btn-outline-success"
|
||||
title="Redimensiona e comprime os ícones para o máximo de <?= IMG_MAX_MODALIDADE ?>px"
|
||||
onclick="return confirm('Otimizar todos os ícones (máx. <?= IMG_MAX_MODALIDADE ?>px)? Os ficheiros originais serão substituídos.')">
|
||||
<i class="bi bi-arrows-angle-contract me-1"></i>Otimizar ícones
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn-primary" href="<?= base_url('views/admin/modalidades/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Modalidade</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>Ícone</th><th>Nome</th><th>Cor</th><th>Ordem</th><th style="width:120px">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$items): ?>
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Nenhuma modalidade.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<tr>
|
||||
<td><?php if ($item['icon']): ?><img src="<?= base_url('uploads/modalidades/icons/' . $item['icon']) ?>" alt="" style="height:32px;"><?php else: ?><span class="text-muted">—</span><?php endif; ?></td>
|
||||
<td><strong><?= htmlspecialchars($item['nome']) ?></strong></td>
|
||||
<td><span style="display:inline-block;width:24px;height:24px;border-radius:4px;background:<?= htmlspecialchars($item['cor']) ?>;border:1px solid rgba(0,0,0,.1)"></span> <?= htmlspecialchars($item['cor']) ?></td>
|
||||
<td><?= (int) $item['item_order'] ?></td>
|
||||
<td>
|
||||
<?php if ($item['deleted_at']): ?>
|
||||
<a class="btn btn-sm btn-outline-info" href="edit.php?id=<?= $item['m_id'] ?>&restore=1">Restaurar</a>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= $item['m_id'] ?>"><i class="bi bi-pencil"></i></a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="?delete=<?= $item['m_id'] ?>" onclick="return confirm('Apagar esta modalidade?')"><i class="bi bi-trash"></i></a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -0,0 +1,82 @@
|
||||
<?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 = ['name' => '', 'birthday' => '', 'at' => '', 'status' => 'ativo', 'description' => ''];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'name' => trim($_POST['name'] ?? ''),
|
||||
'birthday' => trim($_POST['birthday'] ?? ''),
|
||||
'at' => trim($_POST['at'] ?? ''),
|
||||
'status' => $_POST['status'] ?? 'ativo',
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
];
|
||||
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
|
||||
|
||||
// Photo upload
|
||||
$photo_name = null;
|
||||
if (!empty($_FILES['photo']['tmp_name']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) {
|
||||
$ext = strtolower(pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $errors[] = 'Formato de foto inválido.';
|
||||
else {
|
||||
$upload_dir = __DIR__ . '/../../../uploads/team/foto/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
$photo_name = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $upload_dir . $photo_name)) {
|
||||
$errors[] = 'Falha ao guardar a foto.';
|
||||
$photo_name = null;
|
||||
} else {
|
||||
img_optimize($upload_dir . $photo_name, IMG_MAX_TEAM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
$data['photo'] = $photo_name;
|
||||
$id = db_team_create($pdo, $data);
|
||||
session()->setFlashdata('success', 'Membro criado com sucesso.');
|
||||
header('Location: ' . base_url('views/admin/team/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Adicionar Membro';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:700px;">
|
||||
<h1 class="h3 mb-4">Adicionar Membro</h1>
|
||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label class="form-label">Nome <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="name" required value="<?= htmlspecialchars($data['name']) ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Nascimento</label>
|
||||
<input type="date" class="form-control" name="birthday" value="<?= htmlspecialchars($data['birthday']) ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Área</label>
|
||||
<select class="form-select" name="at"><option value="">—</option>
|
||||
<option value="gym" <?= $data['at'] === 'gym' ? 'selected' : '' ?>>Gym</option>
|
||||
<option value="crossfit" <?= $data['at'] === 'crossfit' ? 'selected' : '' ?>>CrossFit</option>
|
||||
</select></div>
|
||||
<div class="col-md-4"><label class="form-label">Estado</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="ativo" <?= $data['status'] === 'ativo' ? 'selected' : '' ?>>Ativo</option>
|
||||
<option value="inativo" <?= $data['status'] === 'inativo' ? 'selected' : '' ?>>Inativo</option>
|
||||
</select></div>
|
||||
<div class="col-md-4"><label class="form-label">Foto</label>
|
||||
<input type="file" class="form-control" name="photo" accept=".jpg,.jpeg,.png,.gif,.webp"></div>
|
||||
<div class="col-12"><label class="form-label">Descrição</label>
|
||||
<textarea class="form-control" name="description" rows="3"><?= htmlspecialchars($data['description']) ?></textarea></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'; ?>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?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; }
|
||||
|
||||
// Handle restore
|
||||
if (isset($_GET['restore'])) {
|
||||
csrf_check();
|
||||
db_team_restore($pdo, $id);
|
||||
session()->setFlashdata('success', 'Membro restaurado.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$item = db_team_get($pdo, $id);
|
||||
if (!$item) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$data = $item;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'name' => trim($_POST['name'] ?? ''),
|
||||
'birthday' => trim($_POST['birthday'] ?? ''),
|
||||
'at' => trim($_POST['at'] ?? ''),
|
||||
'status' => $_POST['status'] ?? 'ativo',
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
];
|
||||
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
|
||||
|
||||
$photo_name = null;
|
||||
if (!empty($_FILES['photo']['tmp_name']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) {
|
||||
$ext = strtolower(pathinfo($_FILES['photo']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $errors[] = 'Formato de foto inválido.';
|
||||
else {
|
||||
$upload_dir = __DIR__ . '/../../../uploads/team/foto/';
|
||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||
$photo_name = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $upload_dir . $photo_name)) {
|
||||
$errors[] = 'Falha ao guardar a foto.';
|
||||
$photo_name = null;
|
||||
} else {
|
||||
img_optimize($upload_dir . $photo_name, IMG_MAX_TEAM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
$changed = db_team_update($pdo, $id, $data, $photo_name);
|
||||
if ($changed) session()->setFlashdata('success', 'Membro atualizado.');
|
||||
else session()->setFlashdata('warning', 'Nenhuma alteração.');
|
||||
header('Location: ' . base_url('views/admin/team/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Editar Membro';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:700px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Editar Membro</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" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label class="form-label">Nome <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="name" required value="<?= htmlspecialchars($data['name'] ?? '') ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Nascimento</label>
|
||||
<input type="date" class="form-control" name="birthday" value="<?= htmlspecialchars($data['birthday'] ?? '') ?>"></div>
|
||||
<div class="col-md-4"><label class="form-label">Área</label>
|
||||
<select class="form-select" name="at"><option value="">—</option>
|
||||
<option value="gym" <?= ($data['at'] ?? '') === 'gym' ? 'selected' : '' ?>>Gym</option>
|
||||
<option value="crossfit" <?= ($data['at'] ?? '') === 'crossfit' ? 'selected' : '' ?>>CrossFit</option>
|
||||
</select></div>
|
||||
<div class="col-md-4"><label class="form-label">Estado</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="ativo" <?= ($data['status'] ?? '') === 'ativo' ? 'selected' : '' ?>>Ativo</option>
|
||||
<option value="inativo" <?= ($data['status'] ?? '') === 'inativo' ? 'selected' : '' ?>>Inativo</option>
|
||||
</select></div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Foto</label>
|
||||
<?php if (!empty($item['photo'])): ?>
|
||||
<div class="mb-2"><img src="<?= base_url('uploads/team/foto/' . $item['photo']) ?>" alt="" style="max-height:80px;border-radius:4px;"></div>
|
||||
<?php endif; ?>
|
||||
<input type="file" class="form-control" name="photo" accept=".jpg,.jpeg,.png,.gif,.webp">
|
||||
</div>
|
||||
<div class="col-12"><label class="form-label">Descrição</label>
|
||||
<textarea class="form-control" name="description" rows="3"><?= htmlspecialchars($data['description'] ?? '') ?></textarea></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'; ?>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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_team_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Membro apagado.');
|
||||
header('Location: ' . base_url('views/admin/team/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optimize existing photos (resize/compress to the team cap)
|
||||
if (isset($_POST['optimize'])) {
|
||||
csrf_check();
|
||||
$stats = img_optimize_dir(__DIR__ . '/../../../uploads/team/foto/', IMG_MAX_TEAM);
|
||||
session()->setFlashdata($stats['failed'] ? 'warning' : 'success', img_stats_message($stats));
|
||||
header('Location: ' . base_url('views/admin/team/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$items = db_team_list($pdo);
|
||||
$page_title = 'Equipa';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Equipa</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="POST" class="mb-0">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" name="optimize" value="1" class="btn btn-outline-success"
|
||||
title="Redimensiona e comprime as fotos da equipa para o máximo de <?= IMG_MAX_TEAM ?>px"
|
||||
onclick="return confirm('Otimizar todas as fotos da equipa (máx. <?= IMG_MAX_TEAM ?>px)? Os ficheiros originais serão substituídos.')">
|
||||
<i class="bi bi-arrows-angle-contract me-1"></i>Otimizar fotos
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn-primary" href="<?= base_url('views/admin/team/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Membro</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 style="width:60px">Foto</th><th>Nome</th><th>Área</th><th>Estado</th><th style="width:120px">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$items): ?>
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Nenhum membro. Clique "Adicionar Membro" para começar.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<tr>
|
||||
<td><?php if ($item['photo']): ?><img src="<?= base_url('uploads/team/foto/' . $item['photo']) ?>" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:50%"><?php else: ?><span style="width:40px;height:40px;border-radius:50%;background:#dee2e6;display:inline-flex;align-items:center;justify-content:center"><i class="bi bi-person text-muted"></i></span><?php endif; ?></td>
|
||||
<td><strong><?= htmlspecialchars($item['name']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($item['at'] ?? '—') ?></td>
|
||||
<td><span class="badge <?= $item['status'] === 'ativo' ? 'bg-success' : 'bg-secondary' ?>"><?= htmlspecialchars($item['status']) ?></span>
|
||||
<?php if ($item['deleted_at']): ?><span class="badge bg-danger ms-1">apagado</span><?php endif; ?></td>
|
||||
<td>
|
||||
<?php if ($item['deleted_at']): ?>
|
||||
<a class="btn btn-sm btn-outline-info" href="edit.php?id=<?= $item['id'] ?>&restore=1">Restaurar</a>
|
||||
<?php else: ?>
|
||||
<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('Apagar este membro?')"><i class="bi bi-trash"></i></a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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 = ['week_day' => '', 'open' => '09:00', 'close' => '18:00'];
|
||||
|
||||
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sabado'=>'Sábado','domingo'=>'Domingo','feriados'=>'Feriados'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'week_day' => trim($_POST['week_day'] ?? ''),
|
||||
'open' => trim($_POST['open'] ?? ''),
|
||||
'close' => trim($_POST['close'] ?? ''),
|
||||
];
|
||||
if ($data['week_day'] === '') $errors[] = 'O dia é obrigatório.';
|
||||
if ($data['open'] === '') $errors[] = 'A hora de abertura é obrigatória.';
|
||||
if ($data['close'] === '') $errors[] = 'A hora de fecho é obrigatória.';
|
||||
|
||||
if (!$errors) {
|
||||
$id = db_whours_create($pdo, $data);
|
||||
session()->setFlashdata('success', 'Horário adicionado.');
|
||||
header('Location: ' . base_url('views/admin/work_hours/index.php'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Adicionar Dia';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:500px;">
|
||||
<h1 class="h3 mb-4">Adicionar Dia</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="mb-3"><label class="form-label">Dia <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="week_day" required>
|
||||
<option value="">Selecionar…</option>
|
||||
<?php foreach ($dias as $val => $label): ?>
|
||||
<option value="<?= $val ?>" <?= $data['week_day'] === $val ? 'selected' : '' ?>><?= $label ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6"><label class="form-label">Abertura <span class="text-danger">*</span></label>
|
||||
<input type="time" class="form-control" name="open" required value="<?= htmlspecialchars($data['open']) ?>"></div>
|
||||
<div class="col-6"><label class="form-label">Fecho <span class="text-danger">*</span></label>
|
||||
<input type="time" class="form-control" name="close" required value="<?= htmlspecialchars($data['close']) ?>"></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'; ?>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?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_whours_get($pdo, $id);
|
||||
if (!$item) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$data = $item;
|
||||
|
||||
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sabado'=>'Sábado','domingo'=>'Domingo','feriados'=>'Feriados'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = [
|
||||
'week_day' => trim($_POST['week_day'] ?? ''),
|
||||
'open' => trim($_POST['open'] ?? ''),
|
||||
'close' => trim($_POST['close'] ?? ''),
|
||||
];
|
||||
if ($data['week_day'] === '') $errors[] = 'O dia é obrigatório.';
|
||||
if ($data['open'] === '') $errors[] = 'A hora de abertura é obrigatória.';
|
||||
if ($data['close'] === '') $errors[] = 'A hora de fecho é obrigatória.';
|
||||
|
||||
if (!$errors) {
|
||||
$changed = db_whours_update($pdo, $id, $data);
|
||||
if ($changed) session()->setFlashdata('success', 'Horário atualizado.');
|
||||
else session()->setFlashdata('warning', 'Nenhuma alteração.');
|
||||
header('Location: ' . base_url('views/admin/work_hours/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Editar Dia';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:500px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Editar Dia</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="mb-3"><label class="form-label">Dia <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="week_day" required>
|
||||
<option value="">Selecionar…</option>
|
||||
<?php foreach ($dias as $val => $label): ?>
|
||||
<option value="<?= $val ?>" <?= ($data['week_day'] ?? '') === $val ? 'selected' : '' ?>><?= $label ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6"><label class="form-label">Abertura <span class="text-danger">*</span></label>
|
||||
<input type="time" class="form-control" name="open" required value="<?= htmlspecialchars(substr($data['open'] ?? '09:00', 0, 5)) ?>"></div>
|
||||
<div class="col-6"><label class="form-label">Fecho <span class="text-danger">*</span></label>
|
||||
<input type="time" class="form-control" name="close" required value="<?= htmlspecialchars(substr($data['close'] ?? '18:00', 0, 5)) ?>"></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'; ?>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?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_whours_delete($pdo, (int) $_GET['delete']);
|
||||
session()->setFlashdata('success', 'Horário removido.');
|
||||
header('Location: ' . base_url('views/admin/work_hours/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$items = db_whours_list($pdo);
|
||||
$page_title = 'Horário de Funcionamento';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
|
||||
$dias = ['segunda'=>'Segunda','terça'=>'Terça','quarta'=>'Quarta','quinta'=>'Quinta','sexta'=>'Sexta','sabado'=>'Sábado','domingo'=>'Domingo','feriados'=>'Feriados'];
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Horário de Funcionamento</h1>
|
||||
<a class="btn btn-primary" href="<?= base_url('views/admin/work_hours/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar Dia</a>
|
||||
</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>Dia</th><th>Abertura</th><th>Fecho</th><th style="width:120px">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$items): ?>
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">Nenhum horário definido.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($dias[$item['week_day']] ?? $item['week_day']) ?></strong></td>
|
||||
<td><?= htmlspecialchars(substr((string) $item['open'], 0, 5)) ?></td>
|
||||
<td><?= htmlspecialchars(substr((string) $item['close'], 0, 5)) ?></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 este dia?')"><i class="bi bi-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
// Password recovery lives on id.andmore.pt — every local form/handler was
|
||||
// removed when this install became SSO-only. This file is a bounce-only stub.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require_once __DIR__ . '/../../config/functions.php';
|
||||
|
||||
$return_to = base_url('views/auth/login.php');
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/forgot_password.php?return_to=' . urlencode($return_to));
|
||||
exit;
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
// SSO-only platform: there is no local login form. All credentials live on
|
||||
// id.andmore.pt. This file exists solely so old links / bookmarks pointing
|
||||
// at /views/auth/login.php still land the visitor on the central form.
|
||||
//
|
||||
// Also acts as the post-login landing for the id.andmore.pt return_to: the
|
||||
// am_sso cookie is now in the browser, sso_resolve_session() picks it up,
|
||||
// and we forward to the dashboard. The `?from=sso` flag prevents an infinite
|
||||
// loop if resolution fails here — we render a clean error instead of
|
||||
// bouncing back to id.andmore.pt (which would just send us back here again).
|
||||
//
|
||||
// Carries through the optional claim intent (?next=session&session_id=X)
|
||||
// from the homepage "vagas disponíveis" cards so that after authenticating
|
||||
// the user lands on their dashboard scrolled to the open session.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require_once __DIR__ . '/../../config/functions.php';
|
||||
|
||||
// Visitor may already have an am_sso cookie — resolve it instead of bouncing
|
||||
// (saves a round-trip).
|
||||
if (empty($_SESSION['user_id']) && identity_is_federated()) {
|
||||
sso_resolve_session($pdo);
|
||||
}
|
||||
|
||||
$next = $_GET['next'] ?? '';
|
||||
$session_id = (int) ($_GET['session_id'] ?? 0);
|
||||
$claim_intent = ($next === 'session' && $session_id > 0);
|
||||
$from_sso = isset($_GET['from']) && $_GET['from'] === 'sso';
|
||||
|
||||
$dashboard_for = function (string $role) use ($claim_intent, $session_id): string {
|
||||
if ($claim_intent && $role === 'client') {
|
||||
return 'views/user/dashboard.php?focus_session=' . $session_id;
|
||||
}
|
||||
return post_login_destination($role);
|
||||
};
|
||||
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
header('Location: ' . base_url($dashboard_for($_SESSION['user']['role'] ?? '')));
|
||||
exit;
|
||||
}
|
||||
|
||||
// We came back from id.andmore.pt but sso_resolve_session() couldn't pick up
|
||||
// a valid session. Bouncing to id.andmore.pt again would loop forever — show
|
||||
// a clean error instead so the user can see what's wrong.
|
||||
if ($from_sso) {
|
||||
http_response_code(500);
|
||||
$am_cookie_present = !empty($_COOKIE[SSO_COOKIE_NAME] ?? '');
|
||||
?><!doctype html><html lang="pt"><head><meta charset="utf-8"><title>Erro de autenticação</title>
|
||||
<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:560px;margin:80px auto;padding:0 1.5rem;color:#1c1c1a;background:#f7f5f1;line-height:1.6}h1{font-weight:300;font-size:1.6rem;margin-bottom:.5rem}code{background:rgba(0,0,0,.05);padding:2px 6px;border-radius:3px;font-size:.9em}.muted{color:#6b6863;font-size:.9rem}a{color:#5a7a62}</style>
|
||||
</head><body>
|
||||
<h1>Não foi possível concluir o login</h1>
|
||||
<p>O servidor Andmore ID autenticou-o, mas esta plataforma não conseguiu carregar a sua sessão.</p>
|
||||
<p class="muted">
|
||||
Cookie <code>am_sso</code>: <?= $am_cookie_present ? 'presente' : '<strong>em falta</strong>' ?>.<br>
|
||||
Plataforma: <code><?= htmlspecialchars(PLATFORM_SLUG) ?></code> · Modo: <code><?= htmlspecialchars(IDENTITY_MODE) ?></code>.
|
||||
</p>
|
||||
<p><a href="<?= htmlspecialchars(base_url('index.php')) ?>">Voltar ao início</a> · <a href="<?= htmlspecialchars(base_url('views/auth/logout.php')) ?>">Tentar novamente</a></p>
|
||||
</body></html><?php
|
||||
exit;
|
||||
}
|
||||
|
||||
// First arrival — bounce to id.andmore.pt for credentials, tagging the
|
||||
// return URL with ?from=sso so the loop-guard above fires on the way back.
|
||||
$return_to = base_url('views/auth/login.php?from=sso'
|
||||
. ($claim_intent ? '&next=session&session_id=' . $session_id : ''));
|
||||
|
||||
$_login_qs = '?return_to=' . urlencode($return_to) . '&platform=' . urlencode(PLATFORM_SLUG);
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_login_qs);
|
||||
exit;
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
|
||||
// Always clear the local session first.
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
|
||||
// Federated mode: always bounce through Andmore ID's logout endpoint so both
|
||||
// the central sso_sessions row and the am_sso cookie are destroyed AND
|
||||
// id.andmore.pt's own PHP session is cleared. The old check for the am_sso
|
||||
// cookie was a bug: if the cookie happened to be absent from $_COOKIE (e.g.
|
||||
// due to a Secure-flag or SameSite edge case), the central logout was skipped,
|
||||
// leaving id.andmore.pt's session alive. That session then caused a redirect
|
||||
// loop — id.andmore.pt auto-logged the user in and bounced them back to the
|
||||
// login stub, which immediately bounced them back to id.andmore.pt, ad inf.
|
||||
//
|
||||
// Return to the public landing page (not the login stub) so the post-logout
|
||||
// redirect from id.andmore.pt lands on a real page and the chain stops.
|
||||
if (identity_is_federated()) {
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/logout.php?return_to=' . urlencode(APP_BASE_URL));
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Location: ' . base_url('views/auth/login.php'));
|
||||
exit;
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
// SSO-only platform: there is no local registration form. Accounts live on
|
||||
// id.andmore.pt and are mirrored locally by sso_resolve_session() the first
|
||||
// time the user lands on this platform. This file exists only so old
|
||||
// /views/auth/register.php links bounce through to the central registration.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require_once __DIR__ . '/../../config/functions.php';
|
||||
|
||||
// Already authenticated? Skip the bounce and land on the right dashboard.
|
||||
if (empty($_SESSION['user_id']) && identity_is_federated()) {
|
||||
sso_resolve_session($pdo);
|
||||
}
|
||||
|
||||
$next = $_GET['next'] ?? '';
|
||||
$session_id = (int) ($_GET['session_id'] ?? 0);
|
||||
$claim_intent = ($next === 'session' && $session_id > 0);
|
||||
|
||||
$dashboard_for = function (string $role) use ($claim_intent, $session_id): string {
|
||||
if ($claim_intent && $role === 'client') {
|
||||
return 'views/user/dashboard.php?focus_session=' . $session_id;
|
||||
}
|
||||
return post_login_destination($role);
|
||||
};
|
||||
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
header('Location: ' . base_url($dashboard_for($_SESSION['user']['role'] ?? '')));
|
||||
exit;
|
||||
}
|
||||
|
||||
$return_to = base_url('views/auth/login.php'
|
||||
. ($claim_intent ? '?next=session&session_id=' . $session_id : ''));
|
||||
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/register.php?return_to=' . urlencode($return_to));
|
||||
exit;
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
// Password reset happens on id.andmore.pt — bounce anyone landing here with
|
||||
// an old reset link so they don't get a "page not working" experience.
|
||||
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require_once __DIR__ . '/../../config/functions.php';
|
||||
|
||||
header('Location: ' . ANDMORE_ID_URL . 'auth/forgot_password.php?return_to='
|
||||
. urlencode(base_url('views/auth/login.php')));
|
||||
exit;
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
// Self-locks after success by writing system_settings.setup_completed_at —
|
||||
// relying only on "is there a sysadmin?" left the door open if anyone ever
|
||||
// truncated the users table.
|
||||
$setupDoneAt = (string) setting('setup_completed_at', '');
|
||||
$alreadyDone = ($setupDoneAt !== '' || admin_exists($pdo));
|
||||
|
||||
if ($alreadyDone) {
|
||||
$success = 'Já existe uma conta de administrador. Esta página já não é necessária — elimine views/auth/setup.php.';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$confirm = $_POST['confirm'] ?? '';
|
||||
|
||||
if (!$name || !$email || !$password) {
|
||||
$error = 'Todos os campos são obrigatórios.';
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$error = 'Endereço de email inválido.';
|
||||
} elseif (strlen($password) < 8) {
|
||||
$error = 'A palavra-passe deve ter pelo menos 8 caracteres.';
|
||||
} elseif ($password !== $confirm) {
|
||||
$error = 'As palavras-passe não coincidem.';
|
||||
} else {
|
||||
$newId = auth_create_sysadmin($pdo, $name, $email, $password);
|
||||
// Latch the gate so a future `users` truncate can't re-open setup.
|
||||
db_update_setting($pdo, 'setup_completed_at', date('Y-m-d H:i:s'), $newId);
|
||||
// Best-effort self-destruct.
|
||||
@unlink(__FILE__);
|
||||
$alreadyDone = true;
|
||||
$success = 'Conta de sysadmin criada. <strong>Elimine este ficheiro (views/auth/setup.php) agora.</strong> <a href="' . base_url('views/auth/login.php') . '">Ir para o login →</a>';
|
||||
}
|
||||
}
|
||||
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:480px; margin-top:80px;">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="mb-1">Configuração inicial</h3>
|
||||
<p class="text-muted small mb-4">Crie a conta de administrador. Elimine este ficheiro depois.</p>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success"><?= $success ?></div>
|
||||
<?php elseif (!$alreadyDone): ?>
|
||||
<form method="POST">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-name">Nome</label>
|
||||
<input type="text" id="setup-name" name="name" class="form-control" required
|
||||
autocomplete="name"
|
||||
value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-email">Email</label>
|
||||
<input type="email" id="setup-email" name="email" class="form-control" required
|
||||
autocomplete="email" inputmode="email"
|
||||
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="setup-password">Palavra-passe <span class="text-muted small">(mín. 8 caracteres)</span></label>
|
||||
<input type="password" id="setup-password" name="password" class="form-control" required minlength="8"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="setup-confirm">Confirmar palavra-passe</label>
|
||||
<input type="password" id="setup-confirm" name="confirm" class="form-control" required
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Criar conta de administrador</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
require_once $repo_root . '/config/functions_backup.php';
|
||||
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/backups.php';
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
$repo_root = dirname(__DIR__, 2);
|
||||
require_once $repo_root . '/config/db.php';
|
||||
require_once $repo_root . '/config/functions.php';
|
||||
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_sysadmin();
|
||||
require $repo_root . '/shared/platform-tools/platform_update.php';
|
||||
@@ -0,0 +1,358 @@
|
||||
<?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();
|
||||
|
||||
$logo_dir = __DIR__ . '/../../assets/img/logo/';
|
||||
$allowed_ext = ['png', 'jpg', 'jpeg', 'svg', 'webp'];
|
||||
$max_upload = 2 * 1024 * 1024; // 2 MB
|
||||
$errors = [];
|
||||
|
||||
// `app_base_url` lives only in .env (per-install topology). It used to be
|
||||
// editable here but the DB-backed value caused tenants to drift when a dump
|
||||
// was cloned, so the field was removed — change APP_BASE_URL in .env instead.
|
||||
$fields = [
|
||||
'app_name' => ['label' => 'Nome da aplicação', 'type' => 'text', 'required' => true],
|
||||
'email_from' => ['label' => 'Email remetente', 'type' => 'email', 'required' => true],
|
||||
'email_from_name' => ['label' => 'Nome do remetente', 'type' => 'text', 'required' => true],
|
||||
'email_subject_prefix' => ['label' => 'Prefixo do assunto', 'type' => 'text', 'required' => false],
|
||||
'reminder_hours' => ['label' => 'Horas de reminder', 'type' => 'number','required' => true],
|
||||
'contact_email' => ['label' => 'Email de contacto público', 'type' => 'email', 'required' => false],
|
||||
'contact_phone_display'=> ['label' => 'Telefone (texto exibido)', 'type' => 'text', 'required' => false],
|
||||
'contact_phone_dial' => ['label' => 'Telefone (formato dial)', 'type' => 'text', 'required' => false],
|
||||
'contact_phone_intl' => ['label' => 'Telefone (formato internacional, sem +)', 'type' => 'text', 'required' => false],
|
||||
'contact_address_line1'=> ['label' => 'Morada — linha 1', 'type' => 'text', 'required' => false],
|
||||
'contact_address_line2'=> ['label' => 'Morada — linha 2', 'type' => 'text', 'required' => false],
|
||||
'social_facebook_url' => ['label' => 'Facebook URL', 'type' => 'url', 'required' => false],
|
||||
'social_instagram_url' => ['label' => 'Instagram URL', 'type' => 'url', 'required' => false],
|
||||
|
||||
// SEO defaults — used by site/includes/head.php when a page doesn't
|
||||
// set its own $page_title / $page_description / $page_image override.
|
||||
'seo_default_title' => ['label' => 'SEO — título por defeito', 'type' => 'text', 'required' => false],
|
||||
'seo_default_description' => ['label' => 'SEO — descrição por defeito', 'type' => 'text', 'required' => false],
|
||||
'seo_keywords' => ['label' => 'SEO — palavras-chave', 'type' => 'text', 'required' => false],
|
||||
'seo_twitter_handle' => ['label' => 'SEO — Twitter handle', 'type' => 'text', 'required' => false],
|
||||
'seo_locale' => ['label' => 'SEO — locale (og:locale)', 'type' => 'text', 'required' => false],
|
||||
'seo_title_separator' => ['label' => 'SEO — separador do título', 'type' => 'text', 'required' => false],
|
||||
];
|
||||
|
||||
$current = settings_all();
|
||||
$values = [];
|
||||
foreach ($fields as $key => $_) {
|
||||
$values[$key] = (string) ($current[$key] ?? '');
|
||||
}
|
||||
$logo_light = (string) ($current['logo_light_filename'] ?? '');
|
||||
$logo_dark = (string) ($current['logo_dark_filename'] ?? '');
|
||||
$seo_image = (string) ($current['seo_default_image_filename'] ?? '');
|
||||
$seo_dir = __DIR__ . '/../../assets/img/seo/';
|
||||
|
||||
function _settings_handle_logo_upload(array $file, string $dir, array $allowed, int $max, ?string $previous, array &$errors, string $kind): ?string {
|
||||
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
return null;
|
||||
}
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = "Falha no upload do logo {$kind}.";
|
||||
return null;
|
||||
}
|
||||
if ($file['size'] > $max) {
|
||||
$errors[] = "O logo {$kind} excede 2 MB.";
|
||||
return null;
|
||||
}
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed, true)) {
|
||||
$errors[] = "Tipo de ficheiro não permitido para o logo {$kind}.";
|
||||
return null;
|
||||
}
|
||||
$name = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) {
|
||||
$errors[] = "Não foi possível guardar o logo {$kind}.";
|
||||
return null;
|
||||
}
|
||||
if ($previous && $previous !== $name) {
|
||||
@unlink($dir . $previous);
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
foreach ($fields as $key => $meta) {
|
||||
$raw = trim((string) ($_POST[$key] ?? ''));
|
||||
$values[$key] = $raw;
|
||||
if ($meta['required'] && $raw === '') {
|
||||
$errors[] = "O campo \"{$meta['label']}\" é obrigatório.";
|
||||
continue;
|
||||
}
|
||||
if ($raw === '') continue;
|
||||
if ($meta['type'] === 'email' && !filter_var($raw, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = "Email inválido em \"{$meta['label']}\".";
|
||||
}
|
||||
if ($meta['type'] === 'url' && !filter_var($raw, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "URL inválido em \"{$meta['label']}\".";
|
||||
}
|
||||
if ($meta['type'] === 'number' && !ctype_digit($raw)) {
|
||||
$errors[] = "\"{$meta['label']}\" tem de ser um número inteiro.";
|
||||
}
|
||||
}
|
||||
|
||||
$new_light = _settings_handle_logo_upload($_FILES['logo_light'] ?? [], $logo_dir, $allowed_ext, $max_upload, $logo_light, $errors, 'claro');
|
||||
$new_dark = _settings_handle_logo_upload($_FILES['logo_dark'] ?? [], $logo_dir, $allowed_ext, $max_upload, $logo_dark, $errors, 'escuro');
|
||||
|
||||
// SEO share image — same upload helper, separate target directory.
|
||||
if (!is_dir($seo_dir)) @mkdir($seo_dir, 0755, true);
|
||||
$seo_allowed = ['png', 'jpg', 'jpeg', 'webp'];
|
||||
$new_seo_image = _settings_handle_logo_upload($_FILES['seo_default_image'] ?? [], $seo_dir, $seo_allowed, $max_upload, $seo_image, $errors, 'SEO');
|
||||
|
||||
if (!$errors) {
|
||||
$uid = (int) $_SESSION['user_id'];
|
||||
foreach ($fields as $key => $meta) {
|
||||
db_update_setting($pdo, $key, $values[$key], $uid);
|
||||
}
|
||||
if ($new_light !== null) {
|
||||
db_update_setting($pdo, 'logo_light_filename', $new_light, $uid);
|
||||
$logo_light = $new_light;
|
||||
}
|
||||
if ($new_dark !== null) {
|
||||
db_update_setting($pdo, 'logo_dark_filename', $new_dark, $uid);
|
||||
$logo_dark = $new_dark;
|
||||
}
|
||||
if ($new_seo_image !== null) {
|
||||
db_update_setting($pdo, 'seo_default_image_filename', $new_seo_image, $uid);
|
||||
$seo_image = $new_seo_image;
|
||||
}
|
||||
header('Location: ' . base_url('views/sysadmin/system_settings.php?updated=1'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
include __DIR__ . '/../../includes/navbar.php';
|
||||
?>
|
||||
<div class="container py-4" style="max-width:900px;">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-sliders me-2"></i>Definições do sistema</h4>
|
||||
<a href="<?= base_url('views/admin/dashboard.php') ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['updated'])): ?>
|
||||
<div class="alert alert-success">Definições atualizadas.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach ($errors as $e): ?>
|
||||
<div class="alert alert-danger"><?= htmlspecialchars($e) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Identidade</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Nome da aplicação</label>
|
||||
<input type="text" name="app_name" class="form-control" required maxlength="150"
|
||||
value="<?= htmlspecialchars($values['app_name']) ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">URL base da instalação</label>
|
||||
<input type="url" class="form-control" readonly disabled
|
||||
value="<?= htmlspecialchars((string) env('APP_BASE_URL')) ?>">
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Definido em <code>.env</code> (<code>APP_BASE_URL</code>).
|
||||
Edite o ficheiro para alterar — não é gerido aqui.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Logo claro (para fundos escuros — navbar)</label>
|
||||
<?php if ($logo_light): ?>
|
||||
<div class="mb-2 p-2 rounded" style="background:#161c18;">
|
||||
<img src="<?= base_url('assets/img/logo/' . rawurlencode($logo_light)) ?>" alt="Logo claro" style="max-height:48px;">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="logo_light" class="form-control" accept=".png,.jpg,.jpeg,.svg,.webp">
|
||||
<div class="form-text">PNG, JPG, SVG ou WebP. Máx. 2 MB.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Logo escuro (para fundos claros — emails)</label>
|
||||
<?php if ($logo_dark): ?>
|
||||
<div class="mb-2 p-2 rounded border" style="background:#ffffff;">
|
||||
<img src="<?= base_url('assets/img/logo/' . rawurlencode($logo_dark)) ?>" alt="Logo escuro" style="max-height:48px;">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="logo_dark" class="form-control" accept=".png,.jpg,.jpeg,.svg,.webp">
|
||||
<div class="form-text">Usado no cabeçalho dos emails.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Email</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Email remetente</label>
|
||||
<input type="email" name="email_from" class="form-control" required
|
||||
value="<?= htmlspecialchars($values['email_from']) ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Nome do remetente</label>
|
||||
<input type="text" name="email_from_name" class="form-control" required maxlength="150"
|
||||
value="<?= htmlspecialchars($values['email_from_name']) ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Prefixo do assunto</label>
|
||||
<input type="text" name="email_subject_prefix" class="form-control" maxlength="100"
|
||||
value="<?= htmlspecialchars($values['email_subject_prefix']) ?>">
|
||||
<div class="form-text">Prefixado a todos os assuntos enviados pelo sistema.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Notificações</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Horas de reminder</label>
|
||||
<input type="number" name="reminder_hours" class="form-control" required min="1" max="168"
|
||||
value="<?= htmlspecialchars($values['reminder_hours']) ?>">
|
||||
<div class="form-text">Antes da sessão.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Contactos públicos</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Email de contacto</label>
|
||||
<input type="email" name="contact_email" class="form-control"
|
||||
value="<?= htmlspecialchars($values['contact_email']) ?>"
|
||||
placeholder="andre@andremoreira.pt">
|
||||
<div class="form-text">Mostrado no site e recebe os pedidos de orçamento e contacto.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Telefone (texto exibido)</label>
|
||||
<input type="text" name="contact_phone_display" class="form-control"
|
||||
value="<?= htmlspecialchars($values['contact_phone_display']) ?>"
|
||||
placeholder="915 435 034">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Telefone (dial / <code>tel:</code>)</label>
|
||||
<input type="text" name="contact_phone_dial" class="form-control font-monospace"
|
||||
value="<?= htmlspecialchars($values['contact_phone_dial']) ?>"
|
||||
placeholder="+351915435034">
|
||||
<div class="form-text">Formato E.164 (com <code>+</code>).</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Telefone (formato WhatsApp <code>wa.me</code>)</label>
|
||||
<input type="text" name="contact_phone_intl" class="form-control font-monospace"
|
||||
value="<?= htmlspecialchars($values['contact_phone_intl']) ?>"
|
||||
placeholder="351915435034">
|
||||
<div class="form-text">Sem <code>+</code> nem espaços. Usado em <code>https://wa.me/<numero></code>.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Morada — linha 1</label>
|
||||
<input type="text" name="contact_address_line1" class="form-control"
|
||||
value="<?= htmlspecialchars($values['contact_address_line1']) ?>"
|
||||
placeholder="Av. Cidade de Coimbra 159">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Morada — linha 2</label>
|
||||
<input type="text" name="contact_address_line2" class="form-control"
|
||||
value="<?= htmlspecialchars($values['contact_address_line2']) ?>"
|
||||
placeholder="3070-761 Praia de Mira">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Facebook URL</label>
|
||||
<input type="url" name="social_facebook_url" class="form-control"
|
||||
value="<?= htmlspecialchars($values['social_facebook_url']) ?>"
|
||||
placeholder="https://www.facebook.com/...">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Instagram URL</label>
|
||||
<input type="url" name="social_instagram_url" class="form-control"
|
||||
value="<?= htmlspecialchars($values['social_instagram_url']) ?>"
|
||||
placeholder="https://www.instagram.com/...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>SEO & partilha social</strong></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
Valores por defeito usados pelas páginas públicas quando não definem o seu próprio título ou descrição.
|
||||
O nome do site (<code>app_name</code>) é usado automaticamente no <code>og:site_name</code> e no fim dos títulos.
|
||||
</p>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Título por defeito</label>
|
||||
<input type="text" name="seo_default_title" class="form-control" maxlength="150"
|
||||
value="<?= htmlspecialchars($values['seo_default_title']) ?>"
|
||||
placeholder="Ex: Fotografia profissional em Portugal">
|
||||
<div class="form-text">Usado em <code><title></code> / <code>og:title</code> quando a página não define um.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Separador do título</label>
|
||||
<input type="text" name="seo_title_separator" class="form-control" maxlength="10"
|
||||
value="<?= htmlspecialchars($values['seo_title_separator']) ?>"
|
||||
placeholder="—">
|
||||
<div class="form-text">Entre o título da página e o nome do site (ex: <code>—</code>, <code>|</code>).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Descrição por defeito</label>
|
||||
<textarea name="seo_default_description" class="form-control" rows="2" maxlength="300"
|
||||
placeholder="Frase curta (até 160 caracteres) que descreve o site."><?= htmlspecialchars($values['seo_default_description']) ?></textarea>
|
||||
<div class="form-text">Usado em <code>meta description</code> / <code>og:description</code>. Ideal: 120–160 caracteres.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Palavras-chave (opcional)</label>
|
||||
<input type="text" name="seo_keywords" class="form-control" maxlength="255"
|
||||
value="<?= htmlspecialchars($values['seo_keywords']) ?>"
|
||||
placeholder="fotografia, batizado, família, eventos">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Twitter handle</label>
|
||||
<input type="text" name="seo_twitter_handle" class="form-control" maxlength="50"
|
||||
value="<?= htmlspecialchars($values['seo_twitter_handle']) ?>"
|
||||
placeholder="@andremoreirapt">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Locale</label>
|
||||
<input type="text" name="seo_locale" class="form-control" maxlength="10"
|
||||
value="<?= htmlspecialchars($values['seo_locale']) ?>"
|
||||
placeholder="pt_PT">
|
||||
<div class="form-text">Formato <code>xx_XX</code>.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Imagem de partilha (Open Graph)</label>
|
||||
<?php if ($seo_image): ?>
|
||||
<div class="mb-2">
|
||||
<img src="<?= base_url('assets/img/seo/' . rawurlencode($seo_image)) ?>"
|
||||
alt="OG image" style="max-height:160px; max-width:100%; border-radius:4px;">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="seo_default_image" class="form-control" accept=".png,.jpg,.jpeg,.webp">
|
||||
<div class="form-text">
|
||||
PNG, JPG ou WebP. Recomendado 1200×630 px (proporção 1.91:1). Máx. 2 MB.
|
||||
Mostrada quando alguém partilha uma página do site no Facebook, WhatsApp, LinkedIn, etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Guardar alterações</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"client"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_login();
|
||||
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<h1 class="h3 mb-4">Olá, <?= htmlspecialchars($user['name']) ?></h1>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<p class="mb-3">Bem-vindo à sua conta FitMe 3070.</p>
|
||||
<a class="btn btn-outline-primary" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-1"></i>O meu perfil</a>
|
||||
<p class="text-muted small mt-3 mb-0">A área de sócio (modalidades, marcações, horário) será disponibilizada num passo seguinte.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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();
|
||||
|
||||
$users = $pdo->query(
|
||||
"SELECT id, name, email, phone, role, is_active, andmore_id, last_login_at, last_login_ua
|
||||
FROM users ORDER BY FIELD(role,'sysadmin','admin','staff','client'), name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<h1 class="h3 mb-4">Utilizadores</h1>
|
||||
<div class="card shadow-sm"><div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="am-datatable table table-hover align-middle">
|
||||
<thead><tr>
|
||||
<th>Nome</th><th>Email</th><th>Perfil</th><th>Ativo</th>
|
||||
<th>Andmore ID</th><th>Último acesso</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($u['name']) ?></td>
|
||||
<td><?= htmlspecialchars($u['email']) ?></td>
|
||||
<td><span class="badge text-bg-secondary"><?= htmlspecialchars(role_label($u['role'])) ?></span></td>
|
||||
<td><?= $u['is_active'] ? '<i class="bi bi-check-circle text-success"></i>' : '<i class="bi bi-x-circle text-muted"></i>' ?></td>
|
||||
<td><?= $u['andmore_id'] ? (int) $u['andmore_id'] : '<span class="text-muted">—</span>' ?></td>
|
||||
<td class="small text-muted">
|
||||
<?php if ($u['last_login_at']): ?>
|
||||
<?= htmlspecialchars(format_date_pt($u['last_login_at'])) ?>
|
||||
· <?= htmlspecialchars(parse_user_agent((string) $u['last_login_ua'])) ?>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../config/db.php';
|
||||
require __DIR__ . '/../../config/functions.php';
|
||||
$user = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"client"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $user; // require_login();
|
||||
|
||||
// Editable per-tenant fields only. Name/email are owned by Andmore ID and synced
|
||||
// on login by sso_resolve_session(); the password is managed centrally.
|
||||
$saved = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$address = trim($_POST['address'] ?? '');
|
||||
$city = trim($_POST['city'] ?? '');
|
||||
$postal_code = trim($_POST['postal_code'] ?? '');
|
||||
$email_opt = isset($_POST['email_opt_out']) ? 1 : 0;
|
||||
$sms_opt = isset($_POST['sms_opt_out']) ? 1 : 0;
|
||||
$marketing = isset($_POST['marketing_consent']) ? 1 : 0;
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET phone=?, address=?, city=?, postal_code=?,
|
||||
email_opt_out=?, sms_opt_out=?, marketing_consent=?
|
||||
WHERE id=?"
|
||||
)->execute([$phone, $address, $city, $postal_code, $email_opt, $sms_opt, $marketing, $user['id']]);
|
||||
$saved = true;
|
||||
}
|
||||
|
||||
$row = db_get_user($pdo, (int) $user['id']) ?? [];
|
||||
include __DIR__ . '/../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:640px;">
|
||||
<h1 class="h3 mb-4">O meu perfil</h1>
|
||||
<?php if ($saved): ?><div class="alert alert-success">Perfil atualizado.</div><?php endif; ?>
|
||||
|
||||
<div class="card shadow-sm mb-3"><div class="card-body">
|
||||
<div class="row mb-2"><div class="col-4 text-muted">Nome</div><div class="col-8"><?= htmlspecialchars($row['name'] ?? '') ?></div></div>
|
||||
<div class="row mb-2"><div class="col-4 text-muted">Email</div><div class="col-8"><?= htmlspecialchars($row['email'] ?? '') ?></div></div>
|
||||
<p class="text-muted small mb-0">Nome, email e palavra-passe são geridos no Andmore ID.
|
||||
<a href="<?= htmlspecialchars(rtrim(ANDMORE_ID_URL, '/')) ?>/" target="_blank" rel="noopener">Gerir conta →</a>
|
||||
</p>
|
||||
</div></div>
|
||||
|
||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-3"><label class="form-label">Telefone</label>
|
||||
<input class="form-control" name="phone" value="<?= htmlspecialchars($row['phone'] ?? '') ?>"></div>
|
||||
<div class="mb-3"><label class="form-label">Morada</label>
|
||||
<input class="form-control" name="address" value="<?= htmlspecialchars($row['address'] ?? '') ?>"></div>
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3"><label class="form-label">Localidade</label>
|
||||
<input class="form-control" name="city" value="<?= htmlspecialchars($row['city'] ?? '') ?>"></div>
|
||||
<div class="col-md-4 mb-3"><label class="form-label">Código postal</label>
|
||||
<input class="form-control" name="postal_code" value="<?= htmlspecialchars($row['postal_code'] ?? '') ?>"></div>
|
||||
</div>
|
||||
<div class="form-check"><input class="form-check-input" type="checkbox" name="email_opt_out" id="eo" <?= !empty($row['email_opt_out']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="eo">Não receber emails</label></div>
|
||||
<div class="form-check"><input class="form-check-input" type="checkbox" name="sms_opt_out" id="so" <?= !empty($row['sms_opt_out']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="so">Não receber SMS</label></div>
|
||||
<div class="form-check mb-3"><input class="form-check-input" type="checkbox" name="marketing_consent" id="mc" <?= !empty($row['marketing_consent']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="mc">Aceito comunicações de marketing</label></div>
|
||||
<button class="btn btn-primary">Guardar</button>
|
||||
</div></form>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||
Reference in new issue
Block a user