Améliore l'édition du site public

This commit is contained in:
Alexandre NOEL 2026-09-10 00:35:23 +02:00
commit 81f259ea6e
11 changed files with 123 additions and 26 deletions

View file

@ -2,6 +2,15 @@
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.18] — 2026-09-09
### Finitions du site public
- présence des mentions légales et de la politique de confidentialité dans le pied de chaque page publique ;
- insertion guidée des images de la bibliothèque dans les textes, avec description alternative ;
- ajout d’une image sans rechargement de la page ni perte des textes en cours de saisie ;
- présentation des réseaux sociaux sous forme de liens identifiables, colorés et accessibles.
## [1.0.0-rc.17] — 2026-09-08
### Interface mobile et médias publics

View file

@ -77,3 +77,7 @@ Création d’un parcours de mise à jour commun à l’interface et à la ligne
## `1.0.0-rc.17` — Utilisation mobile
Remplacement de la barre de navigation horizontale par un menu mobile repliable et fiabilisation des permissions appliquées aux nouvelles photos publiques.
## `1.0.0-rc.18` — Finitions du site public
Déplacement des informations légales dans le pied de page, insertion guidée des images sans perdre les textes en cours d’édition et identification visuelle des réseaux sociaux.

View file

@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
## État du projet
La candidate actuelle est `1.0.0-rc.17`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
La candidate actuelle est `1.0.0-rc.18`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

View file

@ -1 +1 @@
1.0.0-rc.17
1.0.0-rc.18

View file

@ -775,8 +775,22 @@ final class SettingsController
$filename = ImageService::storeUploaded((string) $upload['tmp_name'], $directory, 'site', 'photo');
@chmod($directory, 0755);
AuditService::log('public_site_image_uploaded', '/settings/public-site/image', 'Image du site public ajoutée');
if (str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'filename' => $filename,
'url' => '/media/site/' . rawurlencode($filename),
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
exit();
}
header('Location: /settings/public-site?image=' . rawurlencode($filename));
} catch (Throwable $e) {
if (str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')) {
http_response_code(422);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => SecurityService::publicError($e)], JSON_THROW_ON_ERROR);
exit();
}
header('Location: /settings/public-site?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.17</span>
<h3>Utilisation mobile</h3>
<p class="text-muted">Navigation repliable sur les petits écrans et publication fiable des nouvelles
photographies, y compris depuis l’application Android.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.18</span>
<h3>Site public</h3>
<p class="text-muted">Informations légales accessibles sur chaque page, insertion guidée des images
dans les contenus et liens sociaux plus lisibles.</p>
</div>
</section>
</div>

View file

@ -13,11 +13,16 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st
<?php endif; ?><?php if ($settings['association_email']): ?><div class="contact-item">✉ <span
class="contact-obfuscated"><?= h($obfuscate($settings['association_email'])) ?></span></div>
<?php endif; ?></div>
<div class="chips" style="margin-top:25px">
<?php foreach (['facebook' => 'Facebook','instagram' => 'Instagram','youtube' => 'YouTube','tiktok' => 'TikTok','whatsapp' => 'WhatsApp','linkedin' => 'LinkedIn','x' => 'X','snapchat' => 'Snapchat'] as $network => $label):
<div class="social-links">
<?php $socialNetworks = [
'facebook' => ['Facebook','f'], 'instagram' => ['Instagram','◎'], 'youtube' => ['YouTube','▶'],
'tiktok' => ['TikTok','♪'], 'whatsapp' => ['WhatsApp','☎'], 'linkedin' => ['LinkedIn','in'],
'x' => ['X','𝕏'], 'snapchat' => ['Snapchat','👻'],
];
foreach ($socialNetworks as $network => [$label,$icon]):
$url = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
if ($url === '') continue; ?>
<a class="button secondary" href="<?= h($url) ?>" rel="noopener noreferrer"><?= h($label) ?></a>
<a class="social-link social-<?= h($network) ?>" href="<?= h($url) ?>" target="_blank" rel="noopener noreferrer"><span class="social-icon" aria-hidden="true"><?= h($icon) ?></span><span><?= h($label) ?></span></a>
<?php endforeach; ?>
</div>
<?php if ($lat !== null && $lng !== null): ?><h2 style="margin-top:35px">
@ -27,8 +32,6 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st
</div>
</div>
</section>
<div class="wrap" style="text-align:center"><a href="<?= h($siteUrl('legal')) ?>"><?= h(t('public_site.legal')) ?></a> ·
<a href="<?= h($siteUrl('privacy')) ?>"><?= h(t('public_site.privacy')) ?></a></div>
<?php if ($lat !== null && $lng !== null): ?><script src="/assets/vendor/leaflet/leaflet.js"></script>
<script>
(() => {

View file

@ -373,6 +373,23 @@ if (!function_exists('public_age')) {
background: var(--paper)
}
.social-links { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 25px }
.social-link {
display: inline-flex; align-items: center; gap: 9px; padding: 9px 14px;
border-radius: 999px; color: #fff; font-weight: 800; text-decoration: none;
box-shadow: 0 5px 14px rgba(0, 0, 0, .12); transition: .15s ease
}
.social-link:hover { color: #fff; filter: brightness(.92); transform: translateY(-1px) }
.social-icon { min-width: 1.15em; text-align: center; font-size: 1.05rem; line-height: 1 }
.social-facebook { background: #1877f2 }
.social-instagram { background: linear-gradient(135deg, #833ab4, #e1306c, #f77737) }
.social-youtube { background: #f00 }
.social-tiktok, .social-x { background: #111 }
.social-whatsapp { background: #128c7e }
.social-linkedin { background: #0a66c2 }
.social-snapchat { background: #fffc00; color: #111 }
.social-snapchat:hover { color: #111 }
footer {
margin-top: 55px;
border-top: 1px solid var(--line);
@ -395,6 +412,9 @@ if (!function_exists('public_age')) {
font-size: .9rem
}
.footer-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px }
.footer-links a { color: var(--muted); font-weight: 700 }
.footer-powered a {
color: var(--accent-dark);
font-weight: 800
@ -488,6 +508,10 @@ if (!function_exists('public_age')) {
<?= h($settings['association_rna']) ?></span><?php endif; ?><?php if (($settings['association_rna'] ?? '') !== '' && ($settings['association_siret'] ?? '') !== ''): ?>
· <?php endif; ?><?php if (($settings['association_siret'] ?? '') !== ''): ?><span><?= h(t('public_site.siret')) ?>
: <?= h($settings['association_siret']) ?></span><?php endif; ?></div>
<nav class="footer-links" aria-label="<?= h(t('public_site.legal_privacy')) ?>">
<a href="<?= h($siteUrl('legal')) ?>"><?= h(t('public_site.legal')) ?></a>
<a href="<?= h($siteUrl('privacy')) ?>"><?= h(t('public_site.privacy')) ?></a>
</nav>
</div>
<div class="footer-powered"><?= h(t('public_site.powered_by')) ?> <a
href="https://globinours.fr" target="_blank"

View file

@ -11,20 +11,20 @@
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">🖼️ <?= h(t('public_site.image_library')) ?></h3></div>
<div class="card-body">
<form method="post" action="/settings/public-site/image" enctype="multipart/form-data" class="row g-2 align-items-end mb-3">
<form id="public-image-upload" method="post" action="/settings/public-site/image" enctype="multipart/form-data" class="row g-2 align-items-end mb-3">
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
<div class="col-md"><label class="form-label"><?= h(t('public_site.image_file')) ?></label><input class="form-control" type="file" name="public_site_image" accept="image/jpeg,image/png,image/webp" required></div>
<div class="col-md-auto"><button class="btn btn-outline-primary"><?= h(t('public_site.image_upload')) ?></button></div>
</form>
<div class="form-hint mb-3"><?= h(t('public_site.image_help')) ?></div>
<?php if (!$images): ?><div class="text-muted"><?= h(t('public_site.image_none')) ?></div><?php else: ?>
<div class="row g-3">
<div class="form-hint mb-3" id="public-image-help"><?= h(t('public_site.image_help')) ?></div>
<div class="text-muted<?= $images ? ' d-none' : '' ?>" id="public-image-empty"><?= h(t('public_site.image_none')) ?></div>
<div class="row g-3" id="public-image-library">
<?php foreach ($images as $image): ?><div class="col-6 col-md-3 col-xl-2">
<div class="card h-100"><img src="<?= h($image['url']) ?>" alt="" class="card-img-top" style="height:110px;object-fit:cover">
<div class="card-body p-2"><button type="button" class="btn btn-sm btn-outline-secondary w-100 public-image-insert" data-markup="<?= h('![Image](' . $image['url'] . ')') ?>"><?= h(t('public_site.image_insert')) ?></button></div>
<div class="card-body p-2"><button type="button" class="btn btn-sm btn-outline-secondary w-100 public-image-insert" data-url="<?= h($image['url']) ?>"><?= h(t('public_site.image_insert')) ?></button></div>
</div>
</div><?php endforeach; ?>
</div><?php endif; ?>
</div>
</div>
</div>
<form method="post" action="/settings/public-site/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
@ -162,6 +162,17 @@
</form>
<script>
let activePublicEditor = null;
const imageLibrary = document.getElementById('public-image-library');
const imageHelp = document.getElementById('public-image-help');
const insertImage = (button) => {
const textarea = activePublicEditor || document.querySelector('.public-rich-text');
if (!textarea) return;
const alt = window.prompt(<?= json_encode(t('public_site.image_alt_prompt'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>, '');
if (alt === null) return;
const safeAlt = alt.replace(/[\[\]]/g, '').trim() || <?= json_encode(t('public_site.image_default_alt'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
textarea.setRangeText(`![${safeAlt}](${button.dataset.url})\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
textarea.focus();
};
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
const toolbar = document.createElement('div');
@ -181,15 +192,47 @@ document.querySelectorAll('.public-rich-text').forEach((textarea) => {
});
toolbar.append(button);
});
const imageButton = document.createElement('button');
imageButton.type = 'button';
imageButton.className = 'btn btn-sm btn-outline-primary';
imageButton.textContent = <?= json_encode('🖼️ ' . t('public_site.image_insert'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
imageButton.addEventListener('click', () => {
activePublicEditor = textarea;
imageHelp.textContent = <?= json_encode(t('public_site.image_target_selected'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
imageLibrary.scrollIntoView({behavior: 'smooth', block: 'center'});
});
toolbar.append(imageButton);
textarea.before(toolbar);
});
document.querySelectorAll('.public-image-insert').forEach((button) => {
button.addEventListener('click', () => {
const textarea = activePublicEditor || document.querySelector('.public-rich-text');
if (!textarea) return;
const start = textarea.selectionStart;
textarea.setRangeText(button.dataset.markup + '\n', start, textarea.selectionEnd, 'end');
textarea.focus();
button.addEventListener('click', () => insertImage(button));
});
document.getElementById('public-image-upload').addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const submit = form.querySelector('button');
submit.disabled = true;
try {
const response = await fetch(form.action, {method: 'POST', headers: {'Accept': 'application/json'}, body: new FormData(form)});
const data = await response.json();
if (!response.ok) throw new Error(data.error || <?= json_encode(t('public_site.image_invalid'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>);
const column = document.createElement('div');
column.className = 'col-6 col-md-3 col-xl-2';
column.innerHTML = '<div class="card h-100"><img alt="" class="card-img-top" style="height:110px;object-fit:cover"><div class="card-body p-2"><button type="button" class="btn btn-sm btn-outline-secondary w-100 public-image-insert"></button></div></div>';
const image = column.querySelector('img');
const insert = column.querySelector('button');
image.src = data.url;
insert.dataset.url = data.url;
insert.textContent = <?= json_encode(t('public_site.image_insert'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
insert.addEventListener('click', () => insertImage(insert));
imageLibrary.prepend(column);
document.getElementById('public-image-empty').classList.add('d-none');
form.reset();
insert.click();
} catch (error) {
window.alert(error.message);
} finally {
submit.disabled = false;
}
});
</script>

View file

@ -120,7 +120,7 @@ return [
'accounting.document_save_failed'=>'The document could not be saved.',
'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.',
'public_site.settings_heading'=>'Public website','public_site.theme_custom'=>'Custom','public_site.site_name'=>'Website name','public_site.custom_css'=>'Custom CSS','public_site.custom_css_help'=>'Used by the custom theme. Imports and external resources are blocked.','public_site.invalid_css'=>'The stylesheet contains a forbidden instruction.','public_site.routing_management_help'=>'Management stays at / and the public website at /site.','public_site.publish_animal'=>'Publish this animal on the public website','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','lab_reference.add_species'=>'Add a species to lab reports','lab_reference.copy_from'=>'Copy reference ranges from','lab_reference.target_species'=>'New species','lab_reference.copy_help'=>'Ranges are copied as a starting point and remain editable.','lab_reference.invalid_species'=>'Choose two different valid species.','lab_reference.nothing_copied'=>'This species already has these reference ranges.','statistics.hair_types'=>'Hair types','animals.filtered_by'=>'Filter: :value','service.icad.home_unavailable'=>'The I-CAD website is unavailable','service.icad.search_unavailable'=>'I-CAD search is unavailable','service.icad.record_unavailable'=>'The I-CAD record is unavailable',
'public_site.image_library'=>'Image library','public_site.image_file'=>'Image to add','public_site.image_upload'=>'Upload image','public_site.image_help'=>'Upload an image, place the cursor in a text field, then choose “Insert”. Accepted formats: JPEG, PNG and WebP.','public_site.image_none'=>'No image uploaded.','public_site.image_insert'=>'Insert','public_site.image_invalid'=>'Choose a valid image.',
'public_site.image_library'=>'Image library','public_site.image_file'=>'Image to add','public_site.image_upload'=>'Upload image','public_site.image_help'=>'From the text you want to enrich, choose “Insert”, then select an image. Accepted formats: JPEG, PNG and WebP.','public_site.image_none'=>'No image uploaded.','public_site.image_insert'=>'Insert','public_site.image_invalid'=>'Choose a valid image.','public_site.image_alt_prompt'=>'Briefly describe the image for people who cannot see it:','public_site.image_default_alt'=>'Association image','public_site.image_target_selected'=>'Text selected: now choose an image from the library.',
'rooms.duplicate'=>'A room with this name or code already exists.','settings.species_duplicate'=>'A species with this name or code already exists.',
'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.',
'pricing.adoption_fees_help'=>'Indicative amounts shared by all animals, according to their sterilisation status.','rooms.inactive_help'=>'An unchecked room becomes inactive, while its previous assignments remain in the history. Its internal code never changes.',

View file

@ -120,7 +120,7 @@ return [
'accounting.document_save_failed'=>'Impossible d’enregistrer le document.',
'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.',
'public_site.settings_heading'=>'Site public','public_site.theme_custom'=>'Personnalisé','public_site.site_name'=>'Nom du site','public_site.custom_css'=>'CSS personnalisé','public_site.custom_css_help'=>'Actif avec le thème personnalisé. Les imports et les ressources externes sont bloqués.','public_site.invalid_css'=>'La feuille de style contient une instruction interdite.','public_site.routing_management_help'=>'La gestion reste sur / et le site public sur /site.','public_site.publish_animal'=>'Publier cet animal sur le site public','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','lab_reference.add_species'=>'Ajouter une espèce aux bilans','lab_reference.copy_from'=>'Copier les références de','lab_reference.target_species'=>'Nouvelle espèce','lab_reference.copy_help'=>'Les intervalles sont copiés comme point de départ, puis restent modifiables.','lab_reference.invalid_species'=>'Choisissez deux espèces différentes et valides.','lab_reference.nothing_copied'=>'Cette espèce possède déjà ces références.','statistics.hair_types'=>'Types de poil','animals.filtered_by'=>'Filtre : :value','service.icad.home_unavailable'=>'Le site I-CAD est indisponible','service.icad.search_unavailable'=>'La recherche I-CAD est indisponible','service.icad.record_unavailable'=>'La fiche I-CAD est indisponible',
'public_site.image_library'=>'Bibliothèque d’images','public_site.image_file'=>'Image à ajouter','public_site.image_upload'=>'Ajouter l’image','public_site.image_help'=>'Ajoutez une image, placez le curseur dans un texte puis choisissez « Insérer ». Formats acceptés : JPEG, PNG et WebP.','public_site.image_none'=>'Aucune image ajoutée.','public_site.image_insert'=>'Insérer','public_site.image_invalid'=>'Choisissez une image valide.',
'public_site.image_library'=>'Bibliothèque d’images','public_site.image_file'=>'Image à ajouter','public_site.image_upload'=>'Ajouter l’image','public_site.image_help'=>'Depuis le texte à enrichir, choisissez « Insérer » puis sélectionnez une image. Formats acceptés : JPEG, PNG et WebP.','public_site.image_none'=>'Aucune image ajoutée.','public_site.image_insert'=>'Insérer','public_site.image_invalid'=>'Choisissez une image valide.','public_site.image_alt_prompt'=>'Décrivez brièvement l’image pour les personnes qui ne peuvent pas la voir :','public_site.image_default_alt'=>'Image de l’association','public_site.image_target_selected'=>'Texte sélectionné : choisissez maintenant une image dans la bibliothèque.',
'rooms.duplicate'=>'Une salle portant ce nom ou ce code existe déjà.','settings.species_duplicate'=>'Une espèce portant ce nom ou ce code existe déjà.',
'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.',
'pricing.adoption_fees_help'=>'Montants indicatifs communs à tous les animaux, selon leur état de stérilisation.','rooms.inactive_help'=>'Une salle décochée devient inactive, mais ses anciennes affectations restent dans l’historique. Son code interne ne change jamais.',