/`), content rows carry `gym_id`, and the
-backoffice has an "a editar" gym switcher (`admin_gym_id()` /
-`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym.
-An unpublished gym 404s publicly but previews for staff.
-
-## platform-tools
-
-`shared/platform-tools` is a **git submodule** (pinned `branch = main` in
-`.gitmodules`), not vendored — see `PLATFORM_TOOLS.md` for why it went vendored→submodule→
-back, and don't repeat that mistake without reading it first. `config/db.php` wires it
-via `platform_connect()`: shared auth (above), fleet IP-ban guard (runs on every
-request), the sysadmin router (`platform.php` / `api/platform.php` — Backups,
-Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller,
-just for *those* pages, not the app's own).
-
-## Deploy
-
-Push to `master` → Gitea webhook → `api/platform.php?endpoint=auto_update` → the VPS
-pulls, syncs the submodule, applies pending `database/migration_*.sql` files, done in
-seconds. No staging branch; the VPS checkout **is** what's live. `deploy.sh` (on the
-VPS, not in this repo) is the manual fallback. See `PLATFORM_TOOLS.md`.
-
-## Dependencies
-
-`composer.json`/`composer.lock` are real (`composer install` works, `composer
-audit`/`outdated` are meaningful) — added 2026-09-25, matching the fotografia
-platform's setup. `vendor/PHPMailer/` is the one hand-vendored exception, gitignore-
-allowlisted: `shared/platform-tools/functions_mail.php` hard-codes that exact path
-fleet-wide, so Composer's own `vendor/phpmailer/phpmailer/` exists in parallel but
-isn't what actually gets `require_once`'d. See `ASSETS.md` for frontend asset versions.
-Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the
-public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.
-
-## What's not done (tracked, not silently missing)
-
-The full audit and its live status live in a separate report, not in this repo
-(`CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html` on the maintainer's
-machine). As of 2026-09-25: no real front controller (6.2, above), no central listing
-registry, no DataTables on backoffice listings, no `srcset` (deliberate — uploads are
-already close to their CSS display size, see the audit item), `views/admin/galerias/`
-still has separate create/edit (deliberately — they're not actually a duplicated pair,
-see the audit item). None of these are urgent; none should be "fixed" as a side effect
-of an unrelated change — each is its own piece of work.
-
-## Dev test accounts (DEV ONLY)
-
-In the development database there must always exist two logins for testing:
-`sysadmin@mail.pt` (role sysadmin) and `admin@mail.pt` (role admin), password `123`.
-Ensure/repair them with `php scripts/dev_accounts.php` (CLI only, idempotent upsert with
-`password_hash`, active flags set). The script fails closed: it refuses unless `.env` has
-`APP_ENV=dev` AND `DB_HOST` is `192.168.1.3:3181` or localhost. It must never be run, deployed
-or adapted for production, and these accounts must never exist in a production database.
-
-## Brand feed (2026-10-09)
-- `api/brand.php` is a one-line stub to `shared/platform-tools/api/brand.php` (public JSON: name, logos, colours; README "Brand feed"). Declared in `platform_connect(['brand' => ...])` in `config/db.php`; colours are the `:root` tokens (`--fitme-accent` #c2d500, `--fitme-dark` #0b0b0d, `--fitme-accent-dark` #a6b600). `logo_light` = light-coloured logo (`logo_crossfit_white.svg`), `logo_dark` = `logo_crossfit.svg`.
-- `site_logo_rel()` (config/functions.php) resolves logos for both the feed and `load_email_template()`: setting file in `uploads/public/logo/` (convention), then legacy `assets/img/logo/`, then the static logo. `views/sysadmin/system_settings.php` now uploads logos to `uploads/public/logo/` (dir created on demand, 0755; `uploads/.htaccess` blocks script execution); old files in `assets/img/logo/` still resolve as fallback and in the settings preview. Brand block in `config/db.php` already prefers the setting file via `site_logo_rel()`, else the static SVGs.
-
-## Dev toolbar partilhada (2026-10-09)
-- DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev").
-- Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`).
-- Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`).
diff --git a/assets/css/backoffice.css b/assets/css/backoffice.css
new file mode 100644
index 0000000..bc5b408
--- /dev/null
+++ b/assets/css/backoffice.css
@@ -0,0 +1,351 @@
+/* Backoffice FitMe 3070: tokens da marca, casca (barra lateral, topbar, gaveta
+ em telemóvel) e acertos dos componentes. Carregado por includes/header.php
+ depois do Bootstrap. O Bootstrap nunca se edita: o que muda faz-se com
+ variáveis e com classes próprias (prefixo bo-). */
+
+:root {
+ --bo-accent: #C2D500;
+ --bo-accent-rgb: 194,213,0;
+ --bo-dark: #0a0a0a;
+ --bo-dark-2: #151515;
+ --bo-surface: #eef0f4;
+ --bo-sidebar-w: 264px;
+ --bo-sidebar-w-mini: 80px;
+ --bo-topbar-h: 56px;
+ --bo-ease: .2s ease;
+ /* Texto da barra lateral (escura nos dois temas) */
+ --bo-nav-text: rgba(255,255,255,.62);
+ --bo-nav-text-muted: rgba(255,255,255,.5);
+ --bo-nav-line: rgba(255,255,255,.1);
+ --bo-nav-strong: #fff;
+}
+[data-bs-theme="dark"] { --bo-surface: #121416; }
+
+body { background: var(--bo-surface); font-size: .925rem; }
+a { color: var(--bs-emphasis-color); }
+main { margin-left: var(--bo-sidebar-w); padding-top: calc(var(--bo-topbar-h) + 12px); }
+
+/* ── Barra lateral ───────────────────────────────────────────────────────── */
+/* Sem overflow aqui: o menu do utilizador sai da barra no modo mini.
+ Quem rola é só .bo-sidebar__nav; cabeçalho e utilizador ficam fixos. */
+.sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 100;
+ width: var(--bo-sidebar-w);
+ display: flex;
+ flex-direction: column;
+ background: var(--bo-dark);
+}
+
+/* Cabeçalho numa linha: [☰] [marca] [↗ site público] */
+.bo-sidebar__head {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ height: var(--bo-topbar-h);
+ padding: 0 .75rem;
+ border-bottom: 1px solid var(--bo-nav-line);
+}
+.bo-sidebar__toggle,
+.bo-sidebar__site {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+ min-height: 40px;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--bo-nav-text);
+ font-size: 1.25rem;
+ line-height: 1;
+ text-decoration: none;
+ transition: background var(--bo-ease), color var(--bo-ease);
+}
+.bo-sidebar__site { font-size: 1rem; }
+.bo-sidebar__toggle:hover,
+.bo-sidebar__site:hover { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
+.bo-sidebar__brand {
+ flex: 1;
+ min-width: 0;
+ color: var(--bo-nav-strong);
+ font-weight: 700;
+ font-size: 1.05rem;
+ letter-spacing: .5px;
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.bo-sidebar__brand i { color: var(--bo-accent); }
+
+/* Utilizador logo abaixo do cabeçalho */
+.bo-sidebar__user {
+ flex: none;
+ padding: .6rem .75rem;
+ border-bottom: 1px solid var(--bo-nav-line);
+}
+.bo-user-menu {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ padding: .35rem .45rem;
+ border: 1px solid var(--bo-nav-line);
+ border-radius: 8px;
+ background: transparent;
+ color: var(--bo-nav-strong);
+ text-align: left;
+}
+.bo-user-menu:hover,
+.bo-user-menu.show { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
+.bo-user-menu::after { margin-left: auto; flex: none; }
+.bo-user-menu__avatar {
+ flex: none;
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--bo-accent);
+ color: var(--bo-dark);
+ font-size: .8rem;
+ font-weight: 700;
+}
+.bo-user-menu__text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; line-height: 1.25; }
+.bo-user-menu__name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; font-weight: 600; }
+.bo-user-menu__text .badge { font-size: .62rem; font-weight: 600; }
+.bo-sidebar__user .dropdown-menu { min-width: 14rem; z-index: 1110; }
+
+/* Lista de navegação: a única parte que rola */
+.bo-sidebar__nav {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: .5rem 0 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sidebar .bo-nav-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ min-height: 40px;
+ padding: .5rem 1.25rem;
+ border-left: 3px solid transparent;
+ color: var(--bo-nav-text);
+ font-size: .875rem;
+ transition: background var(--bo-ease), color var(--bo-ease), border-color var(--bo-ease);
+}
+.sidebar .bo-nav-item i { flex: none; width: 1.25rem; text-align: center; font-size: 1.05rem; }
+.sidebar .bo-nav-item:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
+.sidebar .bo-nav-item.active { color: var(--bo-nav-strong); background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
+.sidebar .bo-nav-item.active i { color: var(--bo-accent); }
+.sidebar .bo-nav-item--primary i { font-size: 1.25rem; }
+/* Indentação = cadeia de dados: filhos de um grupo, e de um subgrupo. */
+.sidebar .bo-nav-item--sub { padding-left: 2.3rem; font-size: .84rem; }
+.sidebar .bo-nav-item--sub2 { padding-left: 3.2rem; font-size: .84rem; }
+.bo-nav-item__label { min-width: 0; }
+
+.sidebar .bo-nav-item:focus-visible,
+.bo-nav-group__label:focus-visible,
+.bo-sidebar__toggle:focus-visible,
+.bo-sidebar__site:focus-visible,
+.bo-user-menu:focus-visible,
+.bo-sidebar__brand:focus-visible { outline: 2px solid var(--bo-accent); outline-offset: -2px; }
+
+/* Grupos recolhíveis */
+.bo-nav-group { margin-top: .25rem; }
+.bo-nav-group__label {
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ width: 100%;
+ padding: .55rem 1.25rem;
+ border: 0;
+ background: transparent;
+ color: var(--bo-nav-text-muted);
+ font-family: inherit;
+ font-size: .68rem;
+ font-weight: 700;
+ letter-spacing: .14em;
+ text-align: left;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+.bo-nav-group__label:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.04); }
+.bo-nav-group__icon { flex: none; width: 1.25rem; text-align: center; font-size: 1.1rem; letter-spacing: 0; }
+.bo-nav-group__text { flex: 1; min-width: 0; }
+.bo-nav-group__chevron { font-size: .75rem; letter-spacing: 0; transition: rotate var(--bo-ease); }
+.bo-nav-group__label[aria-expanded="false"] .bo-nav-group__chevron { rotate: -90deg; }
+.bo-nav-group--sub > .bo-nav-group__label { padding-left: 1.9rem; }
+
+/* Corpo recolhível: grelha 1fr -> 0fr anima a altura sem JavaScript a medir. */
+.bo-nav-group__body {
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows var(--bo-ease), visibility var(--bo-ease);
+}
+.bo-nav-group__inner {
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+/* Fechado = classe (clique no grupo da página ativa) ou chave no atributo do
+ posto pelo script do antes da pintura. O grupo da página ativa
+ só fecha por clique (.is-closed), nunca pelo estado guardado. visibility
+ tira os links fechados da ordem de tabulação. Grupo novo = chave nova aqui. */
+:is(.bo-nav-group.is-closed,
+ html[data-bo-groups~="gestao"] .bo-nav-group[data-group="gestao"]:not(.has-active),
+ html[data-bo-groups~="site"] .bo-nav-group[data-group="site"]:not(.has-active),
+ html[data-bo-groups~="horarios"] .bo-nav-group[data-group="horarios"]:not(.has-active),
+ html[data-bo-groups~="comercial"] .bo-nav-group[data-group="comercial"]:not(.has-active),
+ html[data-bo-groups~="legal"] .bo-nav-group[data-group="legal"]:not(.has-active),
+ html[data-bo-groups~="utilizadores"] .bo-nav-group[data-group="utilizadores"]:not(.has-active),
+ html[data-bo-groups~="sistema"] .bo-nav-group[data-group="sistema"]:not(.has-active)) > .bo-nav-group__body {
+ grid-template-rows: 0fr;
+ visibility: hidden;
+}
+
+/* Véu por trás da gaveta em telemóvel */
+.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
+.sidebar-backdrop.show { display: block; }
+
+/* ── Topbar ──────────────────────────────────────────────────────────────── */
+.topbar {
+ position: fixed;
+ top: 0;
+ left: var(--bo-sidebar-w);
+ right: 0;
+ z-index: 99;
+ height: var(--bo-topbar-h);
+ padding: 0 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ background: var(--bs-body-bg);
+ border-bottom: 1px solid var(--bs-border-color);
+}
+.bo-topbar__tools { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
+.menu-toggle {
+ display: none;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 8px;
+ background: var(--bs-body-bg);
+ color: var(--bs-body-color);
+ font-size: 1.35rem;
+ line-height: 1;
+ align-items: center;
+ justify-content: center;
+}
+.theme-toggle { min-width: 44px; min-height: 44px; }
+
+/* ── Marca nos componentes ───────────────────────────────────────────────── */
+.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
+.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
+.badge-role-staff { background: #6f42c1; }
+.badge-role-client { background: #adb5bd; color: #212529; }
+.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
+.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
+.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
+.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
+.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
+[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
+
+/* Summernote (WYSIWYG): alinhado com o aspeto do backoffice */
+.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
+.note-editor .note-editing-area .note-editable { font-size: .95rem; }
+.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
+.note-toolbar { background: var(--bs-tertiary-bg); }
+.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
+
+/* ── Telemóvel: barra lateral em gaveta ──────────────────────────────────── */
+@media (max-width: 991.98px) {
+ .sidebar {
+ transform: translateX(-100%);
+ transition: transform .25s ease;
+ z-index: 1100;
+ box-shadow: 2px 0 18px rgba(0,0,0,.3);
+ }
+ .sidebar.open { transform: translateX(0); }
+ main { margin-left: 0; }
+ .topbar { left: 0; padding: 0 .75rem; }
+ .menu-toggle { display: inline-flex; }
+ /* Tabelas largas rolam dentro do cartão em vez de alargar a página */
+ main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
+}
+@media (max-width: 575.98px) {
+ /* Cabeçalhos de página (título + botões) empilham em vez de transbordar */
+ main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
+ /* 16px nos campos: o iOS não faz zoom ao focar */
+ .form-control, .form-select { font-size: 16px; }
+ /* Margens confortáveis em telemóvel */
+ /* !important: .p-3 é uma utilitária do Bootstrap e tem !important de origem */
+ .p-3 { padding: .85rem !important; }
+}
+
+/* ── Modo mini (só ecrã grande; classe posta no pelo script do ) ── */
+@media (min-width: 992px) {
+ html.bo-mini { --bo-sidebar-w: var(--bo-sidebar-w-mini); }
+ html.bo-mini .bo-sidebar__head {
+ flex-direction: column;
+ justify-content: center;
+ height: auto;
+ padding: .5rem 0;
+ gap: .25rem;
+ }
+ html.bo-mini .bo-sidebar__brand { display: none; }
+ html.bo-mini .bo-sidebar__user { padding: .5rem; }
+ html.bo-mini .bo-user-menu { justify-content: center; padding: .25rem; }
+ html.bo-mini .bo-user-menu__text,
+ html.bo-mini .bo-user-menu::after { display: none; }
+ html.bo-mini .bo-sidebar__nav { padding: .5rem 0; }
+ html.bo-mini .sidebar .bo-nav-item,
+ html.bo-mini .sidebar .bo-nav-item--sub,
+ html.bo-mini .sidebar .bo-nav-item--sub2 { justify-content: center; padding: .5rem; }
+ html.bo-mini .bo-nav-item__label { display: none; }
+ html.bo-mini .bo-nav-item__badge {
+ position: absolute;
+ top: 2px;
+ right: 8px;
+ margin: 0;
+ padding: .15em .4em;
+ font-size: .6rem;
+ }
+ /* Categoria no mini: sigla de texto (data-short), sem ícone nem chevron. */
+ html.bo-mini .bo-nav-group__label,
+ html.bo-mini .bo-nav-group--sub > .bo-nav-group__label { justify-content: center; padding: .55rem .25rem; font-size: 0; }
+ html.bo-mini .bo-nav-group__label::before { content: attr(data-short); font-size: .66rem; font-weight: 700; letter-spacing: .08em; }
+ html.bo-mini .bo-nav-group__text,
+ html.bo-mini .bo-nav-group__icon,
+ html.bo-mini .bo-nav-group__chevron { display: none; }
+}
+
+/* ── Navegação sem piscar: só o conteúdo faz fade; barra e topbar ficam ── */
+@media (prefers-reduced-motion: no-preference) {
+ @view-transition { navigation: auto; }
+ .sidebar { view-transition-name: bo-sidebar; }
+ .topbar { view-transition-name: bo-topbar; }
+ ::view-transition-old(root),
+ ::view-transition-new(root) { animation-duration: .14s; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .sidebar,
+ .sidebar .bo-nav-item,
+ .bo-nav-group__body,
+ .bo-nav-group__chevron { transition: none; }
+}
diff --git a/assets/css/bs-brand.css b/assets/css/bs-brand.css
new file mode 100644
index 0000000..126c6d9
--- /dev/null
+++ b/assets/css/bs-brand.css
@@ -0,0 +1,253 @@
+/* ── Bootstrap re-themed to the FitMe brand ──────────────────────────────────
+ Loaded after bootstrap.min.css (and before backoffice.css). Bootstrap's source
+ is never edited: everything here is --bs-* variables plus the component-scoped
+ variables Bootstrap 5.3 exposes (.btn-*, .form-check-input, .page-link …).
+ The brand colour is a single token, --fitme-accent (a gym's own colour on the
+ public site, see gym_brand_css()); every shade below is derived from it with
+ color-mix(), so changing the brand colour re-themes all of Bootstrap at once.
+ The other semantic colours (success/danger/warning/info) are tuned to sit next
+ to the brand without being confused with it. */
+:root,
+[data-bs-theme="light"],
+[data-bs-theme="dark"] {
+ --brand: var(--fitme-accent, #C2D500);
+ --brand-ink: color-mix(in srgb, var(--brand) 55%, #000); /* text on light surfaces */
+ --brand-on: var(--fitme-on-accent, #0B0B0D); /* text on the brand colour */
+
+ --bs-primary: var(--brand);
+ --bs-primary-rgb: var(--fitme-accent-rgb, 194, 213, 0);
+ --bs-secondary: #3D403E;
+ --bs-secondary-rgb: 61, 64, 62;
+ --bs-success: #2E9E5B;
+ --bs-success-rgb: 46, 158, 91;
+ --bs-danger: #D6322E;
+ --bs-danger-rgb: 214, 50, 46;
+ --bs-warning: #F2A900;
+ --bs-warning-rgb: 242, 169, 0;
+ --bs-info: #2C9FD0;
+ --bs-info-rgb: 44, 159, 208;
+ --bs-light: #F7F8FA;
+ --bs-light-rgb: 247, 248, 250;
+ --bs-dark: #0B0B0D;
+ --bs-dark-rgb: 11, 11, 13;
+
+ --bs-focus-ring-color: color-mix(in srgb, var(--brand) 40%, transparent);
+}
+
+/* Subtle / emphasis / border tints: light theme (tint 80 / 60, shade 60) … */
+:root,
+[data-bs-theme="light"] {
+ --bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 40%, #000);
+ --bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #fff);
+ --bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #fff);
+ --bs-secondary-text-emphasis: #191a19;
+ --bs-secondary-bg-subtle: #e5e6e5;
+ --bs-secondary-border-subtle: #c5c6c5;
+ --bs-success-text-emphasis: #124f2c;
+ --bs-success-bg-subtle: #d5ecde;
+ --bs-success-border-subtle: #acdabd;
+ --bs-danger-text-emphasis: #561411;
+ --bs-danger-bg-subtle: #f7d6d5;
+ --bs-danger-border-subtle: #efadac;
+ --bs-warning-text-emphasis: #613f00;
+ --bs-warning-bg-subtle: #fceecc;
+ --bs-warning-border-subtle: #fadf99;
+ --bs-info-text-emphasis: #11404e;
+ --bs-info-bg-subtle: #d5ecf6;
+ --bs-info-border-subtle: #abd9ec;
+ --bs-link-color: var(--brand-ink);
+ --bs-link-color-rgb: 0, 0, 0;
+ --bs-link-hover-color: color-mix(in srgb, var(--brand) 40%, #000);
+}
+
+/* … dark theme (tint 40, shade 80 / 60) */
+[data-bs-theme="dark"] {
+ --bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 60%, #fff);
+ --bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #000);
+ --bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #000);
+ --bs-secondary-text-emphasis: #b1b2b1;
+ --bs-secondary-bg-subtle: #131413;
+ --bs-secondary-border-subtle: #252726;
+ --bs-success-text-emphasis: #82c59d;
+ --bs-success-bg-subtle: #09200f;
+ --bs-success-border-subtle: #1c5f36;
+ --bs-danger-text-emphasis: #e88380;
+ --bs-danger-bg-subtle: #2b0a09;
+ --bs-danger-border-subtle: #801e1c;
+ --bs-warning-text-emphasis: #f7ca66;
+ --bs-warning-bg-subtle: #302200;
+ --bs-warning-border-subtle: #916800;
+ --bs-info-text-emphasis: #80c5e3;
+ --bs-info-bg-subtle: #0e2833;
+ --bs-info-border-subtle: #1a5f7d;
+ --bs-link-color: var(--brand);
+ --bs-link-color-rgb: var(--fitme-accent-rgb, 194, 213, 0);
+ --bs-link-hover-color: color-mix(in srgb, var(--brand) 75%, #fff);
+}
+
+/* ── Components: Bootstrap hard-codes white text on solid primary/semantic
+ fills; the brand colour is light, so the text must be dark. ─────────────── */
+.btn-primary {
+ --bs-btn-color: var(--brand-on);
+ --bs-btn-bg: var(--brand);
+ --bs-btn-border-color: var(--brand);
+ --bs-btn-hover-color: var(--brand-on);
+ --bs-btn-hover-bg: color-mix(in srgb, var(--brand) 86%, #000);
+ --bs-btn-hover-border-color: color-mix(in srgb, var(--brand) 80%, #000);
+ --bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
+ --bs-btn-active-color: var(--brand-on);
+ --bs-btn-active-bg: color-mix(in srgb, var(--brand) 78%, #000);
+ --bs-btn-active-border-color: color-mix(in srgb, var(--brand) 70%, #000);
+ --bs-btn-disabled-color: var(--brand-on);
+ --bs-btn-disabled-bg: var(--brand);
+ --bs-btn-disabled-border-color: var(--brand);
+}
+
+.btn-outline-primary {
+ --bs-btn-color: var(--brand-ink);
+ --bs-btn-border-color: var(--brand);
+ --bs-btn-hover-color: var(--brand-on);
+ --bs-btn-hover-bg: var(--brand);
+ --bs-btn-hover-border-color: var(--brand);
+ --bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
+ --bs-btn-active-color: var(--brand-on);
+ --bs-btn-active-bg: var(--brand);
+ --bs-btn-active-border-color: var(--brand);
+ --bs-btn-disabled-color: var(--brand-ink);
+ --bs-btn-disabled-border-color: var(--brand);
+}
+[data-bs-theme="dark"] .btn-outline-primary { --bs-btn-color: var(--brand); --bs-btn-disabled-color: var(--brand); }
+
+.btn-secondary {
+ --bs-btn-bg: var(--bs-secondary);
+ --bs-btn-border-color: var(--bs-secondary);
+ --bs-btn-hover-bg: #2f3230;
+ --bs-btn-hover-border-color: #2b2d2c;
+ --bs-btn-active-bg: #2b2d2c;
+ --bs-btn-active-border-color: #262826;
+ --bs-btn-disabled-bg: var(--bs-secondary);
+ --bs-btn-disabled-border-color: var(--bs-secondary);
+}
+.btn-outline-secondary {
+ --bs-btn-color: var(--bs-secondary);
+ --bs-btn-border-color: var(--bs-secondary);
+ --bs-btn-hover-bg: var(--bs-secondary);
+ --bs-btn-hover-border-color: var(--bs-secondary);
+ --bs-btn-active-bg: var(--bs-secondary);
+ --bs-btn-active-border-color: var(--bs-secondary);
+ --bs-btn-disabled-color: var(--bs-secondary);
+ --bs-btn-disabled-border-color: var(--bs-secondary);
+}
+[data-bs-theme="dark"] .btn-outline-secondary { --bs-btn-color: #9a9d9b; --bs-btn-border-color: #5b5f5d; }
+
+.btn-success { --bs-btn-bg: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: #27864d; --bs-btn-hover-border-color: #257d49; --bs-btn-active-bg: #257d49; --bs-btn-active-border-color: #227545; --bs-btn-disabled-bg: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
+.btn-danger { --bs-btn-bg: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: #b62a27; --bs-btn-hover-border-color: #ab2825; --bs-btn-active-bg: #ab2825; --bs-btn-active-border-color: #a02622; --bs-btn-disabled-bg: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
+.btn-warning { --bs-btn-bg: var(--bs-warning); --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: #f5b61f; --bs-btn-hover-border-color: #f4b212; --bs-btn-active-bg: #f6bb2b; --bs-btn-active-border-color: #f4b212; --bs-btn-disabled-bg: var(--bs-warning); --bs-btn-disabled-border-color: var(--bs-warning); }
+.btn-info { --bs-btn-bg: var(--bs-info); --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: #52b0d9; --bs-btn-hover-border-color: #42a9d6; --bs-btn-active-bg: #5bb5dc; --bs-btn-active-border-color: #42a9d6; --bs-btn-disabled-bg: var(--bs-info); --bs-btn-disabled-border-color: var(--bs-info); }
+.btn-outline-success { --bs-btn-color: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: var(--bs-success); --bs-btn-hover-border-color: var(--bs-success); --bs-btn-active-bg: var(--bs-success); --bs-btn-active-border-color: var(--bs-success); --bs-btn-disabled-color: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
+.btn-outline-danger { --bs-btn-color: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: var(--bs-danger); --bs-btn-hover-border-color: var(--bs-danger); --bs-btn-active-bg: var(--bs-danger); --bs-btn-active-border-color: var(--bs-danger); --bs-btn-disabled-color: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
+.btn-outline-warning { --bs-btn-color: #b07a00; --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: var(--bs-warning); --bs-btn-hover-border-color: var(--bs-warning); --bs-btn-active-bg: var(--bs-warning); --bs-btn-active-border-color: var(--bs-warning); --bs-btn-disabled-color: #b07a00; --bs-btn-disabled-border-color: var(--bs-warning); }
+.btn-outline-info { --bs-btn-color: #1f7ea6; --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: var(--bs-info); --bs-btn-hover-border-color: var(--bs-info); --bs-btn-active-bg: var(--bs-info); --bs-btn-active-border-color: var(--bs-info); --bs-btn-disabled-color: #1f7ea6; --bs-btn-disabled-border-color: var(--bs-info); }
+[data-bs-theme="dark"] .btn-outline-warning { --bs-btn-color: var(--bs-warning); --bs-btn-disabled-color: var(--bs-warning); }
+[data-bs-theme="dark"] .btn-outline-info { --bs-btn-color: var(--bs-info); --bs-btn-disabled-color: var(--bs-info); }
+.btn-success, .btn-danger, .btn-info, .btn-outline-success, .btn-outline-danger, .btn-outline-info { --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; }
+.btn-success, .btn-danger, .btn-info { --bs-btn-color: #fff; --bs-btn-disabled-color: #fff; }
+.btn-warning, .btn-outline-warning { --bs-btn-hover-color: #0B0B0D; --bs-btn-active-color: #0B0B0D; }
+.btn-warning { --bs-btn-color: #0B0B0D; --bs-btn-disabled-color: #0B0B0D; }
+
+/* ── Solid fills that use the colour as background ───────────────────────── */
+.text-bg-primary { color: var(--brand-on) !important; background-color: var(--brand) !important; }
+.text-bg-secondary { background-color: var(--bs-secondary) !important; }
+.text-bg-success { background-color: var(--bs-success) !important; }
+.text-bg-danger { background-color: var(--bs-danger) !important; }
+.text-bg-warning { color: #0B0B0D !important; background-color: var(--bs-warning) !important; }
+.text-bg-info { color: #fff !important; background-color: var(--bs-info) !important; }
+.link-primary { color: var(--brand-ink) !important; }
+[data-bs-theme="dark"] .link-primary { color: var(--brand) !important; }
+
+/* ── Forms ───────────────────────────────────────────────────────────────── */
+.form-control:focus, .form-select:focus {
+ border-color: color-mix(in srgb, var(--brand) 70%, #000);
+ box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
+}
+.form-check-input:checked,
+.form-check-input[type="checkbox"]:indeterminate {
+ background-color: var(--brand);
+ border-color: var(--brand);
+}
+/* the tick / dot / knob are inline SVGs with a hard-coded white fill */
+.form-check-input:checked[type="checkbox"] {
+ --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B0B0D' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
+}
+.form-check-input:checked[type="radio"] {
+ --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B0B0D'/%3e%3c/svg%3e");
+}
+.form-check-input:focus { border-color: var(--brand); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
+.form-switch .form-check-input:focus {
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23B9C65A'/%3e%3c/svg%3e");
+}
+.form-switch .form-check-input:checked {
+ --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B0B0D'/%3e%3c/svg%3e");
+}
+.form-range::-webkit-slider-thumb { background-color: var(--brand); }
+.form-range::-webkit-slider-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
+.form-range::-moz-range-thumb { background-color: var(--brand); }
+.form-range::-moz-range-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
+.form-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
+.form-range:focus::-moz-range-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
+.was-validated .form-control:valid, .form-control.is-valid,
+.was-validated .form-select:valid, .form-select.is-valid { border-color: var(--bs-success); }
+.was-validated .form-control:invalid, .form-control.is-invalid,
+.was-validated .form-select:invalid, .form-select.is-invalid { border-color: var(--bs-danger); }
+
+/* ── Navigation & lists ──────────────────────────────────────────────────── */
+.nav {
+ --bs-nav-link-color: var(--bs-link-color);
+ --bs-nav-link-hover-color: var(--bs-link-hover-color);
+}
+.nav-pills {
+ --bs-nav-pills-link-active-color: var(--brand-on);
+ --bs-nav-pills-link-active-bg: var(--brand);
+}
+.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { border-top: 2px solid var(--brand); }
+.pagination {
+ --bs-pagination-color: var(--bs-link-color);
+ --bs-pagination-hover-color: var(--bs-link-hover-color);
+ --bs-pagination-focus-color: var(--bs-link-hover-color);
+ --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
+ --bs-pagination-active-color: var(--brand-on);
+ --bs-pagination-active-bg: var(--brand);
+ --bs-pagination-active-border-color: var(--brand);
+}
+.list-group { --bs-list-group-active-color: var(--brand-on); --bs-list-group-active-bg: var(--brand); --bs-list-group-active-border-color: var(--brand); }
+.dropdown-menu { --bs-dropdown-link-active-color: var(--brand-on); --bs-dropdown-link-active-bg: var(--brand); }
+.accordion {
+ --bs-accordion-active-color: var(--bs-primary-text-emphasis);
+ --bs-accordion-active-bg: var(--bs-primary-bg-subtle);
+ --bs-accordion-btn-focus-border-color: var(--brand);
+ --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
+}
+
+/* ── Feedback ────────────────────────────────────────────────────────────── */
+.progress { --bs-progress-bar-bg: var(--brand); --bs-progress-bar-color: var(--brand-on); }
+.alert-primary {
+ --bs-alert-color: var(--bs-primary-text-emphasis);
+ --bs-alert-bg: var(--bs-primary-bg-subtle);
+ --bs-alert-border-color: var(--bs-primary-border-subtle);
+ --bs-alert-link-color: var(--bs-primary-text-emphasis);
+}
+.table-primary {
+ --bs-table-color: var(--bs-primary-text-emphasis);
+ --bs-table-bg: var(--bs-primary-bg-subtle);
+ --bs-table-border-color: var(--bs-primary-border-subtle);
+ --bs-table-striped-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 94%, #000);
+ --bs-table-hover-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 92%, #000);
+ --bs-table-active-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 90%, #000);
+}
+.border-primary { border-color: var(--brand) !important; }
+.bg-primary { background-color: var(--brand) !important; color: var(--brand-on); }
+.text-primary { color: var(--brand-ink) !important; }
+[data-bs-theme="dark"] .text-primary { color: var(--brand) !important; }
+.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
+.text-primary-emphasis { color: var(--bs-primary-text-emphasis) !important; }
+.border-primary-subtle { border-color: var(--bs-primary-border-subtle) !important; }
diff --git a/assets/js/datatables-init.js b/assets/js/datatables-init.js
index 2d69791..c19c28e 100644
--- a/assets/js/datatables-init.js
+++ b/assets/js/datatables-init.js
@@ -46,8 +46,18 @@ function initDataTable(selector, opts) {
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null;
var $table = jQuery(selector);
if (!$table.length) return null;
+ // Listagem vazia: as páginas desenham uma única linha com a
+ // mensagem, que a DataTables rejeita (nº de colunas errado → erro). Tira-se
+ // a linha e a mensagem dela (com links) passa a ser o emptyTable.
+ var language = DATATABLES_PT_PT;
+ var $rows = $table.find('tbody > tr');
+ var $only = $rows.length === 1 ? $rows.children('td[colspan]') : jQuery();
+ if ($only.length === 1 && $rows.children().length === 1) {
+ language = jQuery.extend({}, DATATABLES_PT_PT, { emptyTable: $only.html() });
+ $rows.remove();
+ }
return $table.DataTable(jQuery.extend(true, {
- language: DATATABLES_PT_PT,
+ language: language,
pageLength: 25,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']],
responsive: true,
diff --git a/config/db.php b/config/db.php
index 2d0eb78..27dd54c 100644
--- a/config/db.php
+++ b/config/db.php
@@ -96,7 +96,17 @@ try {
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
// views/auth/login.php para a nota completa.
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
-platform_connect($pdo, [
+// Pacotes de ícones partilhados (platform?tool=icons, pt_icon_assets()). Só se o
+// submódulo já os tem; sem ele os layouts caem nas cópias locais (fit_icon_assets()).
+// Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.
+$iconsFile = dirname(__DIR__) . '/shared/platform-tools/functions_icons.php';
+if (is_file($iconsFile)) {
+ require_once $iconsFile;
+}
+$iconsOpt = is_file($iconsFile)
+ ? ['icons' => ['packs' => ['bootstrap-icons', 'fontawesome'], 'default' => 'bootstrap-icons']]
+ : [];
+platform_connect($pdo, $iconsOpt + [
'auth' => 'shared',
'roles' => [
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
diff --git a/config/functions.php b/config/functions.php
index 16d6b53..8c6e7bb 100644
--- a/config/functions.php
+++ b/config/functions.php
@@ -27,6 +27,7 @@ require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrat
require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
+require_once __DIR__ . '/functions_icons.php'; // fit_icon_assets(): pacotes de ícones partilhados + recurso local
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
@@ -40,6 +41,7 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
+require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
diff --git a/config/functions_events.php b/config/functions_events.php
index 948e0b8..77185d8 100644
--- a/config/functions_events.php
+++ b/config/functions_events.php
@@ -15,6 +15,7 @@ function events_defaults(): array {
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
'publish_from' => '', 'publish_until' => '', 'is_active' => 1,
'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 1,
+ 'gym_id' => null, // NULL = shown on every gym's homepage
];
}
@@ -117,8 +118,8 @@ function db_events_create(PDO $pdo, array $data): int {
INSERT INTO events
(slug, title, subtitle, summary, description, hero_image, cor, location,
event_starts_at, event_ends_at, publish_from, publish_until,
- is_active, cta_label, cta_url, show_lead_form)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ is_active, cta_label, cta_url, show_lead_form, gym_id)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
");
$stmt->execute([
$d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'],
@@ -127,6 +128,7 @@ function db_events_create(PDO $pdo, array $data): int {
str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
+ $d['gym_id'] ? (int) $d['gym_id'] : null,
]);
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
@@ -141,7 +143,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
slug = ?, title = ?, subtitle = ?, summary = ?, description = ?,
hero_image = ?, cor = ?, location = ?,
event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?,
- is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?
+ is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?, gym_id = ?
WHERE e_id = ?
");
$stmt->execute([
@@ -151,6 +153,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
+ $d['gym_id'] ? (int) $d['gym_id'] : null,
$id,
]);
$__r = $stmt->rowCount() > 0;
@@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void {
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
$path = events_image_dir() . $filename;
if (is_file($path)) @unlink($path);
+ img_fallback_follow($path);
}
// ─── Leads ─────────────────────────────────────────────────────────────────────
diff --git a/config/functions_galerias.php b/config/functions_galerias.php
index 4570373..e6b4bda 100644
--- a/config/functions_galerias.php
+++ b/config/functions_galerias.php
@@ -50,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
-function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
- if ($file['error'] !== UPLOAD_ERR_OK) return null;
+// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
+function gallery_upload_max_bytes(): int {
+ $ini = trim((string) ini_get('upload_max_filesize'));
+ $n = (int) $ini;
+ $n *= match (strtolower(substr($ini, -1))) { 'g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10, default => 1 };
+ return $n > 0 ? min($n, 16 * 1024 * 1024) : 16 * 1024 * 1024;
+}
- if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null;
+// $error recebe o motivo (para a mensagem ao utilizador) quando devolve null.
+function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file, ?string &$error = null): ?string {
+ $max_mb = round(gallery_upload_max_bytes() / 1048576);
+ if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) { $error = "maior que {$max_mb} MB"; return null; }
+ if ($file['error'] !== UPLOAD_ERR_OK) { $error = 'o envio falhou'; return null; }
+
+ if (($file['size'] ?? 0) > gallery_upload_max_bytes()) { $error = "maior que {$max_mb} MB"; return null; }
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
- $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
- if ($ext === null) return null;
+ $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
+ if ($ext === null) { $error = 'formato não suportado'; return null; }
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
- if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null;
+ if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { $error = 'não foi possível gravar'; return null; }
img_optimize($upload_dir . $filename, IMG_MAX_GALLERY);
$stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)");
- $stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]);
+ $stmt->execute([$filename, $ext === 'jxl' ? 'image/jxl' : ($file['type'] ?? 'image/jpeg'), $gallery_id]);
return $filename;
}
diff --git a/config/functions_gyms.php b/config/functions_gyms.php
index 82ffd5a..e71c5ec 100644
--- a/config/functions_gyms.php
+++ b/config/functions_gyms.php
@@ -19,6 +19,7 @@ function gym_sections(): array {
return [
'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)',
+ 'comunidade' => 'Comunidade (Instagram)',
'modalidades' => 'Modalidades',
'equipa' => 'Equipa',
'horario' => 'Horário de aulas',
@@ -41,7 +42,7 @@ function gym_defaults(): array {
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
- 'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
+ 'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'logo' => '', 'item_order' => 0,
];
}
@@ -103,6 +104,32 @@ function gym_section_on(array $gym, string $key): bool {
return !is_array($list) || in_array($key, $list, true);
}
+// Brand colour of a gym as :root overrides for the public site. Derives the
+// hover shade, the text-on-light ink and the text colour that stays readable on
+// the colour (dark on light brands, white on dark ones). '' = stylesheet default.
+function gym_brand_vars(array $gym): string {
+ $hex = strtoupper((string) ($gym['brand_color'] ?? ''));
+ if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return '';
+ [$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))];
+ $shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k));
+ $lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
+ return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86)
+ . ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF');
+}
+
+// The gym's logo (relative path). The main gym falls back to the site's own
+// FitMe 3070 logo, so its navbar button has one before anything is uploaded.
+function gym_logo(array $gym): string {
+ $logo = (string) ($gym['logo'] ?? '');
+ if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg';
+ return $logo;
+}
+
+function gym_brand_css(array $gym): string {
+ $v = gym_brand_vars($gym);
+ return $v === '' ? '' : ':root{' . $v . '}';
+}
+
function gym_url(array $gym, string $path = ''): string {
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
}
@@ -159,8 +186,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
+ 'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
+ 'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'item_order' => (int) ($post['item_order'] ?? 0),
];
+ // Header image + logo come from file inputs (a fresh upload wins, "remover"
+ // clears it, otherwise the current value posted in the hidden field stays).
+ foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) {
+ if (!empty($post[$kind . '_remove'])) $data[$field] = '';
+ $file = $_FILES[$kind . '_file'] ?? null;
+ if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
+ $err = null;
+ $saved = gyms_save_image($file, $kind, $err);
+ if ($saved !== null) $data[$field] = $saved;
+ else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.');
+ }
+ }
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
@@ -173,25 +214,42 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
$other = db_gyms_by_slug($pdo, $slug);
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
}
+ if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) {
+ $errors[] = 'Logótipo: imagem inválida.';
+ }
+ if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
+ $errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
+ }
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
- if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
- $errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
+ if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) {
+ $errors[] = 'Imagem do header: imagem inválida.';
}
return $data;
}
-function _gyms_params(array $d): array {
- return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
- $d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
- $d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
+// Columns written by create/update, in order. brand_color and logo were added by
+// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
+// working (they are just not saved) instead of failing on an unknown column.
+function _gyms_cols(PDO $pdo, array $d): array {
+ $cols = [
+ 'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'],
+ 'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'],
+ 'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'],
+ 'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'],
+ 'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'],
+ ];
+ foreach (['brand_color', 'logo'] as $opt) {
+ if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? '';
+ }
+ $cols['item_order'] = $d['item_order'];
+ return $cols;
}
function db_gyms_create(PDO $pdo, array $d): int {
+ $cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
- $pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
- hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
- google_place_id, has_crossfit, item_order, created_at, updated_at)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
+ $pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
+ . implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
$id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
// Its own contacts row (id = gym id), starting from the gym's name.
@@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
+ $cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
- $params = _gyms_params($d); $params[] = $id;
- $st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
- is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
- seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
- has_crossfit = ?, item_order = ?, updated_at = NOW()
- WHERE id = ?");
- $st->execute($params);
+ $st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
+ $st->execute(array_merge(array_values($cols), [$id]));
$changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit();
@@ -222,6 +276,47 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
return $__r;
}
+// ─── Header image / logo uploads ──────────────────────────────────────────────
+// Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the
+// DB keeps the site-relative path, which is what the public pages already read.
+function gyms_upload_dir(): string {
+ return dirname(__DIR__) . '/uploads/gyms/';
+}
+
+// Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the
+// file under a random name and returns "uploads/gyms/", or null with $error.
+function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string {
+ if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
+ $error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE)
+ ? 'o ficheiro é demasiado grande.' : 'o envio falhou.';
+ return null;
+ }
+ if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; }
+ $allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl'];
+ $ext = upload_image_ext($file, $allowed);
+ if ($ext === null) {
+ $error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).';
+ return null;
+ }
+ $dir = gyms_upload_dir();
+ if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; }
+ $name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
+ if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; }
+ if ($ext !== 'svg' && function_exists('img_optimize')) {
+ img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600);
+ }
+ return 'uploads/gyms/' . $name;
+}
+
+// Deletes an uploaded image that is no longer referenced (never a bundled one).
+function gyms_remove_image(?string $rel): void {
+ $rel = (string) $rel;
+ if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
+ $path = gyms_upload_dir() . basename($rel);
+ if (is_file($path)) @unlink($path);
+ img_fallback_follow($path);
+}
+
// Single-field setter for views/admin/reviews/index.php — that page only
// touches google_place_id, not the rest of the gym record db_gyms_update()
// would validate/audit.
diff --git a/config/functions_icons.php b/config/functions_icons.php
new file mode 100644
index 0000000..688eb33
--- /dev/null
+++ b/config/functions_icons.php
@@ -0,0 +1,18 @@
+ locais de recurso, caminhos para link_tag()/base_url()
+function fit_icon_assets(array $packs, array $localHrefs): string {
+ if (function_exists('pt_icon_assets')) {
+ $html = pt_icon_assets($packs);
+ if ($html !== '') return $html;
+ }
+ $out = '';
+ foreach ($localHrefs as $href) {
+ $out .= '' . "\n";
+ }
+ return $out;
+}
diff --git a/config/functions_images.php b/config/functions_images.php
index d3625c7..48f23eb 100644
--- a/config/functions_images.php
+++ b/config/functions_images.php
@@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string {
}
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
+ // JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões
+ // recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor
+ // ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o
+ // img_optimize() salta-o (não há GD para JXL).
+ if (in_array('jxl', $allowed, true)) {
+ $head = (string) @file_get_contents($tmp, false, null, 0, 12);
+ if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl';
+ }
+
if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
@@ -80,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
+ if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
@@ -177,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array {
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
- if (!is_file($path)) continue;
+ if (!is_file($path) || img_is_jxl_fallback($file)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
- if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
+ if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
@@ -205,3 +215,226 @@ function img_human_bytes(int $bytes): string {
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}
+
+// ─── JPEG XL ─────────────────────────────────────────────────────────────────
+// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
+// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
+// .jxl fica como foi enviado (status "skipped") e o upload não falha.
+// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
+// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
+// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
+// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
+
+const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
+
+// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
+function img_jxl_tools(): array {
+ static $tools = null;
+ if ($tools !== null) return $tools;
+ $imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
+ $find = static function (string $bin): string {
+ if (!function_exists('proc_open')) return '';
+ $dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
+ foreach (array_unique(array_filter($dirs)) as $d) {
+ $p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
+ if (@is_file($p) && @is_executable($p)) return $p;
+ }
+ return '';
+ };
+ return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
+}
+
+function img_jxl_can_decode(): bool {
+ $t = img_jxl_tools();
+ return $t['imagick'] || $t['djxl'] !== '';
+}
+
+// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
+function _img_run(string $bin, array $args, string $out): bool {
+ $proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ if (!is_resource($proc)) return false;
+ stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
+ fclose($pipes[1]); fclose($pipes[2]);
+ clearstatcache(true, $out);
+ return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
+}
+
+function img_jxl_decode(string $jxl, string $png): bool {
+ $t = img_jxl_tools();
+ if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
+ if ($t['imagick']) {
+ try {
+ $im = new \Imagick($jxl);
+ $im->setImageFormat('PNG');
+ return $im->writeImage($png) && is_file($png);
+ } catch (\Throwable $e) { return false; }
+ }
+ return false;
+}
+
+function img_jxl_encode(string $png, string $jxl): bool {
+ $t = img_jxl_tools();
+ if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
+ if ($t['imagick']) {
+ try {
+ $im = new \Imagick($png);
+ $im->setImageFormat('JXL');
+ $im->setImageCompressionQuality(90);
+ return $im->writeImage($jxl) && is_file($jxl);
+ } catch (\Throwable $e) { return false; }
+ }
+ return false;
+}
+
+// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
+function img_is_jxl_fallback(string $name): bool {
+ return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
+}
+
+// Absolute path of the fallback beside $jxl_path, or '' when there is none.
+function img_jxl_fallback(string $jxl_path): string {
+ foreach (['webp', 'jpg', 'png'] as $e) {
+ if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
+ }
+ return '';
+}
+
+// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
+// Callers that move/delete an upload call this too, so the pair stays together.
+function img_fallback_follow(string $from, string $to = ''): void {
+ if (!preg_match('/\.jxl$/i', $from)) return;
+ foreach (['webp', 'jpg', 'png'] as $e) {
+ if (!is_file($from . '.' . $e)) continue;
+ if ($to === '') @unlink($from . '.' . $e);
+ else @rename($from . '.' . $e, $to . '.' . $e);
+ }
+}
+
+function img_jxl_optimize(string $path, int $max_dim): array {
+ $skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
+ $fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
+
+ // Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
+ $old = img_jxl_fallback($path);
+ if ($old !== '' && filemtime($old) >= filemtime($path)) {
+ $d = @getimagesize($old);
+ if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
+ }
+ if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
+
+ $dec = $path . '.dec.png';
+ if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
+ $info = @getimagesize($dec);
+ if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
+ [$w, $h] = $info;
+ if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
+ $src = @imagecreatefrompng($dec);
+ if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
+
+ $scale = min(1, $max_dim / max($w, $h, 1));
+ $nw = max(1, (int) round($w * $scale));
+ $nh = max(1, (int) round($h * $scale));
+ if ($scale < 1) {
+ $dst = imagecreatetruecolor($nw, $nh);
+ imagealphablending($dst, false);
+ imagesavealpha($dst, true);
+ imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
+ imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
+ imagedestroy($src);
+ $src = $dst;
+ }
+
+ // Fallback: WebP keeps transparency; without WebP in GD, JPEG.
+ img_fallback_follow($path);
+ if (function_exists('imagewebp')) {
+ imagesavealpha($src, true);
+ $ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
+ } else {
+ imageinterlace($src, true);
+ $ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
+ }
+
+ // Smaller JXL: re-encode from the resized pixels when there is an encoder.
+ $saved = 0;
+ if ($ok && $scale < 1 && imagepng($src, $dec)) {
+ $tmp = $path . '.opt.tmp';
+ $before = (int) filesize($path);
+ if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
+ if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
+ clearstatcache(true, $path);
+ $saved = max(0, $before - (int) filesize($path));
+ }
+ @unlink($tmp);
+ }
+ imagedestroy($src);
+ @unlink($dec);
+ if (!$ok) return $fail('falha ao gravar a versão alternativa');
+ // The fallback must not look older than the .jxl (idempotence check above).
+ @touch(img_jxl_fallback($path));
+ return ['status' => 'resized', 'saved' => $saved,
+ 'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
+}
+
+// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
+// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
+
+function _img_abs(string $rel): string {
+ return dirname(__DIR__) . '/' . ltrim($rel, '/');
+}
+
+// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
+function img_fallback_rel(string $rel): string {
+ if (!preg_match('/\.jxl$/i', $rel)) return '';
+ $fb = img_jxl_fallback(_img_abs($rel));
+ return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
+}
+
+// Where only one image fits (lightbox, og:image, preload, e-mail): the
+// fallback of a .jxl when there is one, else the file itself.
+function img_compat_rel(string $rel): string {
+ return img_fallback_rel($rel) ?: $rel;
+}
+
+// for $rel; a .jxl with a fallback becomes with the JXL source
+// first. $attrs: extra attributes (trusted names, values escaped here);
+// width/height come from the file (or its fallback) unless given.
+function img_tag(string $rel, array $attrs = []): string {
+ $fb = img_fallback_rel($rel);
+ $src = $fb ?: $rel;
+ if (!isset($attrs['width'])) {
+ $d = @getimagesize(_img_abs($src));
+ if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; }
+ }
+ $a = '';
+ foreach ($attrs as $k => $v) {
+ if ($v === null || $v === false) continue;
+ $a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"');
+ }
+ $img = ' ';
+ if ($fb === '') return $img;
+ return '' . $img . '';
+}
+
+// CSS for a style="" attribute: url(fallback) first, then image-set() with the
+// JXL, so browsers without image-set()/JXL keep the fallback.
+function img_css_bg(string $rel): string {
+ $q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')";
+ $fb = img_fallback_rel($rel);
+ if ($fb === '') return 'background-image:' . $q($rel);
+ $type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg';
+ return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))';
+}
+
+// Photos directly inside a project-relative folder ("uploads/gallery/3/"),
+// sorted, without the .jxl fallbacks.
+function img_list_dir(string $dir_rel): array {
+ $dir_rel = rtrim($dir_rel, '/') . '/';
+ $out = [];
+ foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) {
+ $name = basename($p);
+ if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue;
+ $out[] = $dir_rel . $name;
+ }
+ sort($out);
+ return $out;
+}
diff --git a/config/functions_instagram.php b/config/functions_instagram.php
new file mode 100644
index 0000000..e216813
--- /dev/null
+++ b/config/functions_instagram.php
@@ -0,0 +1,188 @@
+ /
+// instagram_token_ (token encrypted, or INSTAGRAM_TOKEN_ in .env).
+// Same cache pattern as functions_reviews.php: config/cache/instagram_.cache.php,
+// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails
+// are downloaded at refresh time to uploads/instagram// (Instagram CDN
+// URLs expire), so visitors never wait on Instagram.
+
+const INSTAGRAM_GRAPH = 'https://graph.facebook.com/v21.0/';
+
+function instagram_use_gym(array $gym): void {
+ $GLOBALS['_instagram_gym'] = $gym;
+}
+
+function instagram_gym(): array {
+ return $GLOBALS['_instagram_gym'] ?? current_gym();
+}
+
+function _instagram_gid(): int {
+ return (int) (instagram_gym()['id'] ?? 1);
+}
+
+function instagram_config(): array {
+ $gid = _instagram_gid();
+ return [
+ 'user_id' => preg_replace('/\D+/', '', (string) setting('instagram_user_id_' . $gid, '')),
+ 'token' => setting_secret('instagram_token_' . $gid, 'INSTAGRAM_TOKEN_' . $gid),
+ 'max' => max(3, min(24, (int) setting('instagram_max', 8))),
+ 'cache_hours' => max(1, (int) setting('instagram_cache_hours', 6)),
+ ];
+}
+
+function instagram_configured(): bool {
+ $c = instagram_config();
+ return $c['user_id'] !== '' && $c['token'] !== '';
+}
+
+function _instagram_cache_path(): string {
+ return __DIR__ . '/cache/instagram_' . _instagram_gid() . '.cache.php';
+}
+
+function _instagram_media_dir(): string {
+ return dirname(__DIR__) . '/uploads/instagram/' . _instagram_gid() . '/';
+}
+
+function instagram_cache_read(): ?array {
+ $path = _instagram_cache_path();
+ if (!is_file($path)) return null;
+ $data = @include $path;
+ return is_array($data) ? $data : null;
+}
+
+function _instagram_cache_write(array $data): void {
+ $path = _instagram_cache_path();
+ if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
+ $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
+ if (@file_put_contents($tmp, " true,
+ CURLOPT_CONNECTTIMEOUT => 3,
+ CURLOPT_TIMEOUT => 6,
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ ]);
+ $raw = curl_exec($ch);
+ $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $cerr = curl_error($ch);
+ curl_close($ch);
+ if ($raw === false) return [null, 'Sem ligação ao Instagram: ' . $cerr];
+ $json = json_decode((string) $raw, true);
+ if ($status !== 200) {
+ $msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : '';
+ return [$json, 'Instagram respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')];
+ }
+ return [is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida do Instagram.'];
+}
+
+// Saves one thumbnail locally; returns the uploads-relative path or ''.
+function _instagram_save_thumb(string $src, string $id): string {
+ if (!preg_match('#^https://[a-z0-9.-]+\.(cdninstagram\.com|fbcdn\.net)/#i', $src)) return '';
+ $dir = _instagram_media_dir();
+ $rel = 'uploads/instagram/' . _instagram_gid() . '/' . $id . '.jpg';
+ if (is_file($dir . $id . '.jpg')) return $rel;
+ $ch = curl_init($src);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2,
+ CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ ]);
+ $bin = curl_exec($ch);
+ curl_close($ch);
+ $info = (is_string($bin) && $bin !== '' && strlen($bin) < 4 * 1024 * 1024) ? @getimagesizefromstring($bin) : false;
+ if (!$info || $info['mime'] !== 'image/jpeg') return '';
+ if (!is_dir($dir)) @mkdir($dir, 0775, true);
+ return @file_put_contents($dir . $id . '.jpg', $bin) ? $rel : '';
+}
+
+// Fetch the tagged media now and rewrite the cache. Returns the cache array.
+function instagram_refresh(): array {
+ $c = instagram_config();
+ $old = instagram_cache_read() ?? [];
+ if ($c['user_id'] === '' || $c['token'] === '') {
+ return $old + ['error' => 'Falta o ID da conta ou o token.'];
+ }
+ // Ask for a few extra: private posts are never returned, and some come without a picture.
+ $url = INSTAGRAM_GRAPH . $c['user_id'] . '/tags?' . http_build_query([
+ 'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,username',
+ 'limit' => min(50, $c['max'] + 6),
+ 'access_token' => $c['token'],
+ ]);
+ [$json, $err] = _instagram_request($url);
+
+ if ($err !== '') {
+ // Keep serving the previous copy; retry after an hour, not a full window.
+ $data = $old;
+ $data['error'] = $err;
+ $data['error_at'] = time();
+ $data['fetched_at'] = time() - max(0, $c['cache_hours'] * 3600 - 3600);
+ _instagram_cache_write($data);
+ error_log('[instagram] ' . $err);
+ return $data;
+ }
+
+ $posts = [];
+ foreach ((array) ($json['data'] ?? []) as $m) {
+ if (count($posts) >= $c['max']) break;
+ $id = preg_replace('/\D+/', '', (string) ($m['id'] ?? ''));
+ if ($id === '' || empty($m['permalink'])) continue;
+ // Videos/reels: the cover (thumbnail_url); photos and albums: media_url.
+ $src = (string) (($m['media_type'] ?? '') === 'VIDEO' ? ($m['thumbnail_url'] ?? '') : ($m['media_url'] ?? ''));
+ $img = $src !== '' ? _instagram_save_thumb($src, $id) : '';
+ if ($img === '') continue;
+ $posts[] = [
+ 'id' => $id,
+ 'image' => $img,
+ 'type' => (string) ($m['media_type'] ?? ''),
+ 'permalink' => (string) $m['permalink'],
+ 'username' => (string) ($m['username'] ?? ''),
+ 'caption' => (string) ($m['caption'] ?? ''),
+ 'timestamp' => (string) ($m['timestamp'] ?? ''),
+ ];
+ }
+ // Drop thumbnails of posts that are no longer shown.
+ $keep = array_flip(array_column($posts, 'id'));
+ foreach (glob(_instagram_media_dir() . '*.jpg') ?: [] as $f) {
+ if (!isset($keep[basename($f, '.jpg')])) @unlink($f);
+ }
+ $data = ['fetched_at' => time(), 'posts' => $posts, 'error' => ''];
+ _instagram_cache_write($data);
+ return $data;
+}
+
+// Cached data, refreshed when older than the cache window (one refresh at a time).
+function instagram_get(): array {
+ $c = instagram_config();
+ $cache = instagram_cache_read();
+ $fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600;
+ if ($fresh || !instagram_configured()) return $cache ?? [];
+
+ $lock = @fopen(_instagram_cache_path() . '.lock', 'c');
+ if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
+ $cache = instagram_refresh();
+ flock($lock, LOCK_UN);
+ }
+ if ($lock) fclose($lock);
+ return $cache ?? [];
+}
+
+// Posts the homepage shows (only those whose thumbnail is on disk), or [] to hide the section.
+function site_instagram(?array $gym = null): array {
+ if ($gym !== null) instagram_use_gym($gym);
+ try { $data = instagram_get(); } catch (Throwable $e) { error_log('[instagram] ' . $e->getMessage()); return []; }
+ $root = dirname(__DIR__) . '/';
+ return array_values(array_filter((array) ($data['posts'] ?? []), fn($p) => is_file($root . $p['image'])));
+}
diff --git a/config/functions_site.php b/config/functions_site.php
index f9d96c9..3ccb1f4 100644
--- a/config/functions_site.php
+++ b/config/functions_site.php
@@ -29,14 +29,38 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
return $row;
}
+/** Whether the home page renders section $sec for $gym (same data checks as site/index.php). */
+function site_section_has_content(array $gym, string $sec): bool {
+ $pdo = $GLOBALS['pdo'] ?? null;
+ $gid = (int) $gym['id'];
+ if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
+ return match ($sec) {
+ 'modalidades' => (bool) site_modalidades($pdo, $gid),
+ 'equipa' => (bool) site_team_active($pdo, $gid),
+ 'horario' => (bool) site_horario($pdo, '', $gid),
+ 'mensalidades' => (bool) site_memberships($pdo, $gid),
+ 'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
+ 'comunidade' => (bool) site_instagram($gym),
+ default => true,
+ };
+}
+
+/** Team members shown on the site (status «ativo»). */
+function site_team_active(PDO $pdo, ?int $gym_id = null): array {
+ return array_values(array_filter(site_team($pdo, $gym_id), fn($d) => ($d['status'] ?? '') === 'ativo'));
+}
+
// Top navigation (label => absolute URL): anchors on the gym's own page, only
-// for the sections that gym shows, plus its CrossFit page when it has one.
+// for the sections that gym shows and has content for, plus its CrossFit page when it has one.
function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]];
- foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
+ foreach (['Galeria' => 'galeria', 'Comunidade' => 'comunidade', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
- if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
+ if (!gym_section_on($gym, $sec)) continue;
+ // A section with nothing to show isn't rendered, so no link to it either.
+ if (!site_section_has_content($gym, $sec)) continue;
+ $items[$label] = ['#' . $sec, $sec];
}
$nav = [];
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
diff --git a/config/functions_trash.php b/config/functions_trash.php
index e8ed8a4..abd3f56 100644
--- a/config/functions_trash.php
+++ b/config/functions_trash.php
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
if ($action === 'delete' && is_file($live)) {
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
@rename($live, $trash);
+ img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
} elseif ($action === 'restore' && is_file($trash)) {
@rename($trash, $live);
+ img_fallback_follow($trash, $live);
} elseif ($action === 'purge') {
if (is_file($trash)) @unlink($trash);
+ img_fallback_follow($trash);
}
}
diff --git a/database/migration_20261013_gym_brand_color.sql b/database/migration_20261013_gym_brand_color.sql
new file mode 100644
index 0000000..71b4f5b
--- /dev/null
+++ b/database/migration_20261013_gym_brand_color.sql
@@ -0,0 +1,8 @@
+-- Cor de marca por ginásio (hex #RRGGBB). Vazio = verde FitMe por defeito.
+-- Ginásio 1 = verde; o ginásio 2 usa vermelho (definir em Gestão → Ginásios).
+-- Safe to re-run.
+ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `brand_color` VARCHAR(7) NOT NULL DEFAULT '' AFTER `has_crossfit`;
+UPDATE `gyms` SET `brand_color` = '#C2D500' WHERE `id` = 1 AND `brand_color` = '';
+
+-- Logótipo do ginásio (caminho relativo, ex.: site/assets/img/logos/ginasio.svg).
+ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
diff --git a/database/migration_20261026_gym_logo.sql b/database/migration_20261026_gym_logo.sql
new file mode 100644
index 0000000..2fd0afe
--- /dev/null
+++ b/database/migration_20261026_gym_logo.sql
@@ -0,0 +1,4 @@
+-- Logótipo do ginásio. A coluna foi acrescentada a migration_20261013_gym_brand_color.sql
+-- depois de essa migração já ter corrido nalgumas bases (dev incluída), que por isso
+-- ficaram sem ela. Safe to re-run.
+ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
diff --git a/dev_login.php b/dev_login.php
index bb046a1..25486a4 100644
--- a/dev_login.php
+++ b/dev_login.php
@@ -13,7 +13,8 @@ if (!is_file($_dev_shared)) { http_response_code(404); exit; } // submódulo s
require_once $_dev_shared;
platform_dev_login_handle([
- 'roles' => ['admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'],
+ 'roles' => ['client' => 'client@mail.pt', 'staff' => 'staff@mail.pt',
+ 'admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'],
'bootstrap' => function () { global $pdo; require __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/functions.php'; },
'csrf_ok' => fn(): bool => is_string($_POST['_csrf'] ?? null) && is_string($_SESSION['csrf_token'] ?? null)
&& $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']),
diff --git a/includes/auth_footer.php b/includes/auth_footer.php
index d70c087..af57b6d 100644
--- a/includes/auth_footer.php
+++ b/includes/auth_footer.php
@@ -4,5 +4,6 @@
= script_tag('assets/js/password-toggle.js') ?>
+
diff --git a/includes/auth_header.php b/includes/auth_header.php
index 69deafe..2d65a91 100644
--- a/includes/auth_header.php
+++ b/includes/auth_header.php
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
})();
-
+
+ = fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
+
diff --git a/includes/dev_toolbar.php b/includes/dev_toolbar.php
index fdc4889..4a86f4b 100644
--- a/includes/dev_toolbar.php
+++ b/includes/dev_toolbar.php
@@ -1,11 +1,11 @@
base_url('dev_login'),
'logout_url' => base_url('views/auth/logout.php'),
- 'roles' => ['admin' => 'Admin', 'sysadmin' => 'Sysadmin'],
+ 'roles' => ['client' => 'Cliente', 'staff' => 'Staff', 'admin' => 'Admin', 'sysadmin' => 'Sysadmin'],
'csrf_field' => 'csrf_field',
]);
diff --git a/includes/env_ribbon.php b/includes/env_ribbon.php
new file mode 100644
index 0000000..21f349d
--- /dev/null
+++ b/includes/env_ribbon.php
@@ -0,0 +1,62 @@
+ in both includes/header.php (backoffice) and
+ * site/includes/head.php (public site). It hangs off html::before / html::after
+ * rather than a wrapper element, so no page markup has to change and nothing
+ * can shift the layout: the frame is fixed, on top, and click-through.
+ *
+ * Reads env() directly rather than the APP_ENV constant, because the public
+ * site does not always load config/functions.php.
+ */
+
+$_env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
+if ($_env === 'prod' || $_env === 'production') return;
+?>
+
diff --git a/includes/footer.php b/includes/footer.php
index bee17b4..33eb6b2 100644
--- a/includes/footer.php
+++ b/includes/footer.php
@@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () {
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
})();
-// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
-// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
+// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
+// o texto das categorias, lembrado em localStorage); em telemovel abre/fecha a
+// gaveta por cima do conteudo. Um so manipulador para os dois botoes. O CSS
+// decide o que cada estado mostra; o estado inicial ja veio do .
(function () {
var sb = document.getElementById('sidebar');
- var tg = document.getElementById('menuToggle');
var bd = document.getElementById('sidebarBackdrop');
- if (!sb || !tg) return;
- var mq = window.matchMedia('(max-width: 991.98px)');
- function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); }
- function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); }
- tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); });
- if (bd) bd.addEventListener('click', close);
- sb.querySelectorAll('.nav-link').forEach(function (a) {
- a.addEventListener('click', function () { if (mq.matches) close(); });
+ var toggles = document.querySelectorAll('[data-sidebar-toggle]');
+ var root = document.documentElement;
+ var nav = sb && sb.querySelector('.bo-sidebar__nav');
+ if (!sb || !toggles.length) return;
+ var mq = window.matchMedia('(min-width: 992px)');
+ function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
+
+ function sync() {
+ var desktop = mq.matches;
+ var open = desktop ? !root.classList.contains('bo-mini') : sb.classList.contains('open');
+ toggles.forEach(function (t) {
+ t.setAttribute('aria-expanded', open ? 'true' : 'false');
+ t.setAttribute('aria-label', open ? (desktop ? 'Reduzir menu' : 'Fechar menu') : (desktop ? 'Expandir menu' : 'Abrir menu'));
+ });
+ if (bd) bd.classList.toggle('show', !desktop && open);
+ document.body.style.overflow = (!desktop && open) ? 'hidden' : '';
+ }
+ function setDrawer(open) { sb.classList.toggle('open', open); sync(); }
+
+ toggles.forEach(function (t) {
+ t.addEventListener('click', function () {
+ if (mq.matches) {
+ store('bo-sidebar-mini', root.classList.toggle('bo-mini') ? '1' : '0');
+ sync();
+ } else {
+ setDrawer(!sb.classList.contains('open'));
+ }
+ });
});
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
- window.addEventListener('resize', function () { if (!mq.matches) close(); });
+ if (bd) bd.addEventListener('click', function () { setDrawer(false); });
+ sb.querySelectorAll('.bo-nav-item').forEach(function (a) {
+ a.addEventListener('click', function () { if (!mq.matches) setDrawer(false); });
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && sb.classList.contains('open')) {
+ setDrawer(false);
+ var o = document.querySelector('.menu-toggle');
+ if (o && o.offsetParent) o.focus();
+ }
+ });
+ if (mq.addEventListener) mq.addEventListener('change', function () { setDrawer(false); });
+ sync();
+
+ // Grupos recolhiveis. O estado inicial veio do (atributo no );
+ // aqui so se liga o clique e se mantem atributo, armazenamento e aria alinhados.
+ var saved = {};
+ try { saved = JSON.parse(localStorage.getItem('bo-nav-groups') || '{}'); } catch (e) {}
+ function groupOpen(g) {
+ if (g.classList.contains('has-active')) return !g.classList.contains('is-closed');
+ return !saved[g.getAttribute('data-group')];
+ }
+ function paint(g) {
+ g.querySelector('[data-group-toggle]').setAttribute('aria-expanded', groupOpen(g) ? 'true' : 'false');
+ }
+ var groups = sb.querySelectorAll('.bo-nav-group[data-group]');
+ groups.forEach(function (g) {
+ g.querySelector('[data-group-toggle]').addEventListener('click', function () {
+ var key = g.getAttribute('data-group');
+ var willClose = groupOpen(g);
+ if (g.classList.contains('has-active')) g.classList.toggle('is-closed', willClose);
+ if (willClose) saved[key] = true; else delete saved[key];
+ store('bo-nav-groups', JSON.stringify(saved));
+ root.setAttribute('data-bo-groups', Object.keys(saved).join(' '));
+ groups.forEach(paint);
+ });
+ });
+ groups.forEach(paint);
+
+ // A lista rola sozinha: lembrar a posicao entre paginas e mostrar o item ativo.
+ if (nav) {
+ try { nav.scrollTop = +sessionStorage.getItem('bo-nav-scroll') || 0; } catch (e) {}
+ var cur = nav.querySelector('[aria-current="page"]');
+ if (cur) {
+ var r = cur.getBoundingClientRect(), n = nav.getBoundingClientRect();
+ if (r.top < n.top || r.bottom > n.bottom) cur.scrollIntoView({ block: 'center' });
+ }
+ addEventListener('pagehide', function () {
+ try { sessionStorage.setItem('bo-nav-scroll', nav.scrollTop); } catch (e) {}
+ });
+ }
})();
+
+
-
+
+
+ = bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
+ = bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
+ = bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
+
+ = bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
+ 'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
+
+ = bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
+ 'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
+
+
+
+
+
-
diff --git a/site/includes/footer.php b/site/includes/footer.php
index f982464..3c6285f 100644
--- a/site/includes/footer.php
+++ b/site/includes/footer.php
@@ -32,6 +32,7 @@
+
@@ -39,5 +40,6 @@
= script_tag('assets/vendor/jquery/jquery-3.7.1.min.js') ?>
= script_tag('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>
= script_tag('site/assets/js/main.js') ?>
+
diff --git a/site/includes/gallery.php b/site/includes/gallery.php
index 4b9972c..d7ee110 100644
--- a/site/includes/gallery.php
+++ b/site/includes/gallery.php
@@ -1,13 +1,15 @@
-// lightbox with prev / next, arrow keys, Esc and swipe (main.js).
-$files = glob(rtrim($gallery_path, '/') . '/*.jpg');
-if (!$files || !is_array($files)) $files = [];
+// Gallery partial — an endless horizontal strip (home "instalações" and the
+// CrossFit page): full-height photos alternate with 2x2 grids of four. main.js
+// scrolls it by itself, lets you click-and-drag (mouse) or swipe (touch), pauses
+// while you interact and loops seamlessly over the repeated copies. Clicking a
+// tile (without dragging) opens it full size in a |