fix(gyms): saving no longer fails before the brand-colour migration; upload header image and logo instead of typing paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
965a123834
commit
a85bec6851
5 files changed
+104
-26
No files matched your search
@@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on
|
|||||||
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
|
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
|
||||||
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
|
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
|
||||||
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
|
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
|
||||||
Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (path, same migration); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour.
|
Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (same migration); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour.
|
||||||
An unpublished gym 404s publicly but previews for staff.
|
An unpublished gym 404s publicly but previews for staff.
|
||||||
|
|
||||||
## platform-tools
|
## platform-tools
|
||||||
|
|||||||
+76
-17
@@ -181,6 +181,18 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
|
|||||||
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
|
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
|
||||||
'item_order' => (int) ($post['item_order'] ?? 0),
|
'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'] ?? [])));
|
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
|
||||||
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
|
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
|
||||||
|
|
||||||
@@ -194,30 +206,41 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
|
|||||||
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
|
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)$~i', $data['logo'])) {
|
if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g)$~i', $data['logo'])) {
|
||||||
$errors[] = 'Logótipo: caminho de uma imagem do site (ex.: site/assets/img/logos/gym2.svg).';
|
$errors[] = 'Logótipo: imagem inválida.';
|
||||||
}
|
}
|
||||||
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
|
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
|
||||||
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
|
$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['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'])) {
|
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).';
|
$errors[] = 'Imagem do header: imagem inválida.';
|
||||||
}
|
}
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _gyms_params(array $d): array {
|
// Columns written by create/update, in order. brand_color and logo were added by
|
||||||
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
|
// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
|
||||||
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
|
// working (they are just not saved) instead of failing on an unknown column.
|
||||||
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['brand_color'], $d['logo'], $d['item_order']];
|
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 {
|
function db_gyms_create(PDO $pdo, array $d): int {
|
||||||
|
$cols = _gyms_cols($pdo, $d);
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
|
$pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
|
||||||
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
|
. implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
|
||||||
google_place_id, has_crossfit, brand_color, logo, item_order, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
|
||||||
$id = (int) $pdo->lastInsertId();
|
$id = (int) $pdo->lastInsertId();
|
||||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
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.
|
// Its own contacts row (id = gym id), starting from the gym's name.
|
||||||
@@ -232,14 +255,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
|
|||||||
|
|
||||||
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||||||
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
||||||
|
$cols = _gyms_cols($pdo, $d);
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$params = _gyms_params($d); $params[] = $id;
|
$st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
|
||||||
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
|
$st->execute(array_merge(array_values($cols), [$id]));
|
||||||
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
|
|
||||||
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
|
|
||||||
has_crossfit = ?, brand_color = ?, logo = ?, item_order = ?, updated_at = NOW()
|
|
||||||
WHERE id = ?");
|
|
||||||
$st->execute($params);
|
|
||||||
$changed = $st->rowCount() > 0;
|
$changed = $st->rowCount() > 0;
|
||||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
@@ -248,6 +267,46 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
|||||||
return $__r;
|
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/<name>", 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'] : ['svg', 'png', 'webp', 'jpg', 'jpeg'];
|
||||||
|
$ext = upload_image_ext($file, $allowed);
|
||||||
|
if ($ext === null) {
|
||||||
|
$error = $kind === 'hero' ? 'usa JPG, PNG ou WebP.' : 'usa SVG, PNG, WebP ou JPG (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);
|
||||||
|
}
|
||||||
|
|
||||||
// Single-field setter for views/admin/reviews/index.php — that page only
|
// 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()
|
// touches google_place_id, not the rest of the gym record db_gyms_update()
|
||||||
// would validate/audit.
|
// would validate/audit.
|
||||||
|
|||||||
@@ -41,9 +41,16 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
|
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Imagem do header</label>
|
<label class="form-label" for="hero_file">Imagem do header</label>
|
||||||
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||||
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div>
|
<div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['hero_image'])) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
||||||
|
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp">
|
||||||
|
<div class="form-text">JPG, PNG ou WebP (até 6 MB). Vazio = a do ginásio principal.</div>
|
||||||
|
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||||
|
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="hero_remove" value="1" id="hero_remove"><label class="form-check-label small" for="hero_remove">Remover a imagem atual</label></div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Galeria das instalações</label>
|
<label class="form-label">Galeria das instalações</label>
|
||||||
@@ -87,9 +94,19 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Logótipo (botão no menu dos outros ginásios)</label>
|
<label class="form-label" for="logo_file">Logótipo (botão no menu dos outros ginásios)</label>
|
||||||
<input class="form-control font-monospace" name="logo" maxlength="255" placeholder="site/assets/img/logos/ginasio.svg" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
|
<?php if (($data['logo'] ?? '') !== ''): ?>
|
||||||
<div class="form-text">Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.</div>
|
<div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['logo'])) ?>" alt="" class="rounded border p-2" style="max-width:100%;max-height:70px;background:#e9ecef"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="hidden" name="logo" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
|
||||||
|
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg">
|
||||||
|
<div class="form-text">SVG, PNG, WebP ou JPG (até 6 MB). Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.</div>
|
||||||
|
<?php if (!_gyms_has_col($pdo, 'gyms', 'logo')): ?>
|
||||||
|
<div class="form-text text-warning">Falta aplicar a migração <code>migration_20261013_gym_brand_color.sql</code> (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($data['logo'] ?? '') !== ''): ?>
|
||||||
|
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="logo_remove" value="1" id="logo_remove"><label class="form-check-label small" for="logo_remove">Remover o logótipo atual</label></div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6 col-lg-4">
|
<div class="col-sm-6 col-lg-4">
|
||||||
<label class="form-label" for="brand_color">Cor da marca</label>
|
<label class="form-label" for="brand_color">Cor da marca</label>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
||||||
</div>
|
</div>
|
||||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
<?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">
|
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php include __DIR__ . '/_form.php'; ?>
|
<?php include __DIR__ . '/_form.php'; ?>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
$changed = db_gyms_update($pdo, $id, $data);
|
$changed = db_gyms_update($pdo, $id, $data);
|
||||||
|
// Uploaded images that were replaced or removed are no longer referenced.
|
||||||
|
foreach (['hero_image', 'logo'] as $f) if (($item[$f] ?? '') !== ($data[$f] ?? '')) gyms_remove_image($item[$f] ?? '');
|
||||||
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
|
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
|
||||||
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
||||||
exit;
|
exit;
|
||||||
@@ -47,7 +49,7 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
|
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php include __DIR__ . '/_form.php'; ?>
|
<?php include __DIR__ . '/_form.php'; ?>
|
||||||
<div class="mt-4 d-flex gap-2">
|
<div class="mt-4 d-flex gap-2">
|
||||||
|
|||||||
Reference in new issue
Block a user