first commit
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
||||
# ============================================
|
||||
# Environnement & Configuration
|
||||
# ============================================
|
||||
.env
|
||||
|
||||
# ============================================
|
||||
# Dépendances Composer
|
||||
# ============================================
|
||||
# Reconstruites dans l'image par composer install
|
||||
vendor/
|
||||
|
||||
# ============================================
|
||||
# Dépendances NPM
|
||||
# ============================================
|
||||
# Reconstruites dans l'image par npm install
|
||||
node_modules/
|
||||
|
||||
# ============================================
|
||||
# Assets générés
|
||||
# ============================================
|
||||
# Reconstruits dans l'image par npm run build
|
||||
public/assets/
|
||||
|
||||
# ============================================
|
||||
# Données persistantes (bind mounts)
|
||||
# ============================================
|
||||
# Montés depuis l'hôte au démarrage — ne pas inclure dans l'image
|
||||
data/
|
||||
public/media/
|
||||
database/*.sqlite
|
||||
|
||||
# ============================================
|
||||
# Cache & Logs
|
||||
# ============================================
|
||||
var/
|
||||
coverage/
|
||||
.php-cs-fixer.cache
|
||||
.phpstan/
|
||||
.phpunit.result.cache
|
||||
|
||||
# ============================================
|
||||
# Tests & Documentation
|
||||
# ============================================
|
||||
tests/
|
||||
docs/
|
||||
|
||||
# ============================================
|
||||
# Versioning
|
||||
# ============================================
|
||||
.git/
|
||||
|
||||
# ============================================
|
||||
# IDE & OS
|
||||
# ============================================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{package.json,package-lock.json}]
|
||||
indent_size = 2
|
||||
81
.env.example
Normal file
81
.env.example
Normal file
@@ -0,0 +1,81 @@
|
||||
# =============================================================================
|
||||
# Général
|
||||
# =============================================================================
|
||||
|
||||
# Environnement de l'application.
|
||||
# Valeurs possibles : development, test, production
|
||||
APP_ENV=development
|
||||
|
||||
# Nom de l'application.
|
||||
# Utilisé notamment dans l'interface, le flux RSS et certains emails.
|
||||
APP_NAME="NETslim"
|
||||
|
||||
# URL publique de base de l'application.
|
||||
# Utilisée pour générer les liens absolus dans les emails et le flux RSS.
|
||||
# Exemples :
|
||||
# Développement : http://localhost:8080
|
||||
# Production : https://app.exemple.com
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
# Fuseau horaire utilisé par l'application.
|
||||
TIMEZONE=Europe/Paris
|
||||
|
||||
# Proxies de confiance autorisés à fournir les en-têtes X-Forwarded-For
|
||||
# et X-Forwarded-Proto.
|
||||
#
|
||||
# Laisse vide en développement local sans reverse proxy.
|
||||
# En Docker avec le Nginx fourni, docker-compose définit généralement `*`
|
||||
# pour les services internes.
|
||||
#
|
||||
# Exemples :
|
||||
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||
# TRUSTED_PROXIES=*
|
||||
TRUSTED_PROXIES=
|
||||
|
||||
# =============================================================================
|
||||
# Administration
|
||||
# =============================================================================
|
||||
|
||||
# Compte administrateur initial.
|
||||
# Ces valeurs sont utilisées uniquement lors du provisionnement initial
|
||||
# pour créer le premier compte administrateur.
|
||||
# ADMIN_PASSWORD doit contenir au moins 12 caractères.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme12345
|
||||
|
||||
# Destination de retour vers le back-office après connexion ou refus
|
||||
# d'autorisation. Le blog pointe vers le tableau de bord /admin.
|
||||
ADMIN_HOME_PATH=/admin
|
||||
|
||||
# =============================================================================
|
||||
# Email
|
||||
# =============================================================================
|
||||
|
||||
# Configuration SMTP utilisée pour l'envoi des emails applicatifs
|
||||
# (par exemple : réinitialisation de mot de passe).
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=noreply@example.com
|
||||
MAIL_PASSWORD=your_smtp_password
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM=noreply@example.com
|
||||
MAIL_FROM_NAME="NETslim"
|
||||
|
||||
# =============================================================================
|
||||
# Uploads
|
||||
# =============================================================================
|
||||
|
||||
# Taille maximale autorisée pour les fichiers uploadés, en octets.
|
||||
# En production, cette valeur doit rester inférieure ou égale à
|
||||
# upload_max_filesize et post_max_size côté PHP.
|
||||
UPLOAD_MAX_SIZE=5242880
|
||||
|
||||
# =============================================================================
|
||||
# Session
|
||||
# =============================================================================
|
||||
|
||||
# Nom du cookie de session PHP.
|
||||
# Change cette valeur si plusieurs applications PHP partagent le même domaine,
|
||||
# afin d'éviter les collisions de cookie de session.
|
||||
SESSION_NAME=netslim_session
|
||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# ============================================
|
||||
# Environnement & Configuration
|
||||
# ============================================
|
||||
.env
|
||||
|
||||
# ============================================
|
||||
# Dépendances Composer
|
||||
# ============================================
|
||||
vendor/
|
||||
|
||||
# ============================================
|
||||
# Dépendances NPM
|
||||
# ============================================
|
||||
node_modules/
|
||||
|
||||
# ============================================
|
||||
# Assets générés
|
||||
# ============================================
|
||||
public/assets/
|
||||
|
||||
# ============================================
|
||||
# Base de données
|
||||
# ============================================
|
||||
database/*.sqlite
|
||||
database/*.sqlite-shm
|
||||
database/*.sqlite-wal
|
||||
database/.provision.lock
|
||||
|
||||
# ============================================
|
||||
# Cache & Logs
|
||||
# ============================================
|
||||
coverage/
|
||||
var/
|
||||
.php-cs-fixer.cache
|
||||
.phpstan/
|
||||
.phpunit.result.cache
|
||||
|
||||
# ============================================
|
||||
# Media
|
||||
# ============================================
|
||||
public/media/
|
||||
|
||||
# ============================================
|
||||
# Docker volumes
|
||||
# ============================================
|
||||
data/
|
||||
|
||||
# ============================================
|
||||
# IDE & OS
|
||||
# ============================================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
36
.php-cs-fixer.dist.php
Normal file
36
.php-cs-fixer.dist.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$directories = array_values(array_filter([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
__DIR__ . '/config',
|
||||
], static fn (string $directory): bool => is_dir($directory)));
|
||||
|
||||
$finder = PhpCsFixer\Finder::create()
|
||||
->in($directories)
|
||||
->name('*.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
return (new PhpCsFixer\Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setFinder($finder)
|
||||
->setRules([
|
||||
'@PSR12' => true,
|
||||
'array_indentation' => true,
|
||||
'binary_operator_spaces' => ['default' => 'single_space'],
|
||||
'blank_line_after_opening_tag' => true,
|
||||
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try']],
|
||||
'class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'one']],
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'declare_strict_types' => true,
|
||||
'final_class' => false,
|
||||
'no_unused_imports' => true,
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'ordered_types' => ['sort_algorithm' => 'alpha'],
|
||||
'single_import_per_statement' => true,
|
||||
'single_line_empty_body' => true,
|
||||
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
|
||||
]);
|
||||
25
CONTRIBUTING.md
Normal file
25
CONTRIBUTING.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Contribuer à netslim-blog
|
||||
|
||||
Merci de contribuer au projet.
|
||||
|
||||
## Règles simples
|
||||
|
||||
- une évolution du domaine `Post` ou du module `Site` se fait dans ce dépôt ;
|
||||
- une évolution transverse (`Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy`, `Media`) se fait dans `netslim-core` ;
|
||||
- tout changement métier doit être accompagné de tests ciblés ;
|
||||
- toute modification de comportement visible doit s’accompagner d’une mise à jour documentaire si elle change la compréhension du projet.
|
||||
|
||||
## Avant d’ouvrir une MR
|
||||
|
||||
```bash
|
||||
composer qa
|
||||
composer frontend:build
|
||||
```
|
||||
|
||||
Fais aussi une passe manuelle si tu touches au back-office :
|
||||
- `/admin`
|
||||
- `/admin/settings`
|
||||
- `/admin/audit-log`
|
||||
- `/admin/notifications`
|
||||
- `/admin/posts`
|
||||
- `/admin/media`
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NETig
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# netslim-blog
|
||||
|
||||
`netslim-blog` est une application blog construite au-dessus de `netslim-core`.
|
||||
|
||||
Le dépôt fournit :
|
||||
- le domaine métier `Post` ;
|
||||
- un module applicatif `Site` qui intègre les briques transverses du core ;
|
||||
- les templates, assets et points d’entrée HTTP du projet ;
|
||||
- la configuration applicative (`config/modules.php`).
|
||||
|
||||
## Dépendance vers netslim-core
|
||||
|
||||
Le projet consomme `netig/netslim-core` depuis le dépôt Git en HTTPS.
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://git.netig.net/netig/netslim-core.git"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"netig/netslim-core": "^0.3@dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Démonstration des modules du core
|
||||
|
||||
L’application active et démontre les modules suivants :
|
||||
- `Identity` pour l’authentification et l’autorisation fine ;
|
||||
- `Settings` pour les réglages persistants du site ;
|
||||
- `AuditLog` pour tracer les actions d’administration ;
|
||||
- `Notifications` pour l’envoi et l’historique d’emails transactionnels ;
|
||||
- `Taxonomy` pour la classification des contenus ;
|
||||
- `Media` pour la médiathèque ;
|
||||
- `Post` pour le domaine blog local.
|
||||
|
||||
Les pages d’administration transverses se trouvent sous :
|
||||
- `/admin`
|
||||
- `/admin/settings`
|
||||
- `/admin/audit-log`
|
||||
- `/admin/notifications`
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Local (PHP intégré + assets compilés localement)
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer frontend:install
|
||||
composer frontend:build
|
||||
cp .env.example .env
|
||||
composer provision
|
||||
composer start
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
docker compose run --rm provision
|
||||
```
|
||||
|
||||
## Composition active
|
||||
|
||||
Le manifeste applicatif est défini dans `config/modules.php`.
|
||||
|
||||
Il active :
|
||||
- `KernelModule`
|
||||
- `IdentityModule`
|
||||
- `SettingsModule`
|
||||
- `AuditLogModule`
|
||||
- `NotificationsModule`
|
||||
- `TaxonomyModule`
|
||||
- `MediaModule`
|
||||
- `SiteModule`
|
||||
- `PostModule`
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/APPLICATION.md` présente l’application livrée ;
|
||||
- `docs/ARCHITECTURE.md` décrit la frontière entre le blog et `netslim-core` ;
|
||||
- `docs/DEVELOPMENT.md` sert de guide quotidien ;
|
||||
- `docs/FRONTEND.md` couvre Twig, SCSS et JavaScript ;
|
||||
- `docs/DEPLOYMENT.md` couvre Docker et les répertoires persistants ;
|
||||
- `netslim-core/docs/PUBLIC_API.md` reste la référence sur les points d’intégration stables du package partagé.
|
||||
|
||||
Les tests et scripts CLI initialisent explicitement les runtime paths du blog, de sorte que le core résolve toujours le manifest de modules et les répertoires persistants contre ce projet et non contre `vendor/`.
|
||||
166
assets/js/media-admin.js
Normal file
166
assets/js/media-admin.js
Normal file
@@ -0,0 +1,166 @@
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var page = document.getElementById('media-admin-page');
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
var uploadForm = document.getElementById('media-upload-form');
|
||||
var uploadButton = document.getElementById('media-upload-submit');
|
||||
var uploadInput = document.getElementById('media-upload-input');
|
||||
var feedback = document.getElementById('media-upload-feedback');
|
||||
var isPickerMode = page.dataset.pickerMode === '1';
|
||||
|
||||
function setFeedback(message, isError) {
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.textContent = message;
|
||||
feedback.style.color = isError ? '#b91c1c' : '';
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
var helper = document.createElement('textarea');
|
||||
helper.value = text;
|
||||
helper.setAttribute('readonly', 'readonly');
|
||||
helper.style.position = 'absolute';
|
||||
helper.style.left = '-9999px';
|
||||
document.body.appendChild(helper);
|
||||
helper.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(helper);
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(url) {
|
||||
try {
|
||||
return new URL(String(url || ''), window.location.origin).href;
|
||||
} catch (error) {
|
||||
return String(url || '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageHtml(url, mediaId) {
|
||||
return '<img src="' + String(url)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>') + '" alt="" data-media-id="' + Number(mediaId) + '">';
|
||||
}
|
||||
|
||||
function flashButtonLabel(button, message, isError) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!button.dataset.originalLabel) {
|
||||
button.dataset.originalLabel = button.textContent.trim();
|
||||
}
|
||||
|
||||
if (button.dataset.restoreTimerId) {
|
||||
window.clearTimeout(Number(button.dataset.restoreTimerId));
|
||||
}
|
||||
|
||||
button.textContent = message;
|
||||
|
||||
if (isError) {
|
||||
button.classList.add('btn--danger');
|
||||
}
|
||||
|
||||
button.dataset.restoreTimerId = String(window.setTimeout(function () {
|
||||
button.textContent = button.dataset.originalLabel || '';
|
||||
button.classList.remove('btn--danger');
|
||||
delete button.dataset.restoreTimerId;
|
||||
}, 1800));
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadForm || !uploadButton || !uploadInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploadInput.files || uploadInput.files.length === 0) {
|
||||
setFeedback('Sélectionnez une image avant de téléverser.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadButton.disabled = true;
|
||||
setFeedback('Téléversement en cours…', false);
|
||||
|
||||
try {
|
||||
var response = await fetch(uploadForm.dataset.uploadUrl || uploadForm.action || window.location.href, {
|
||||
method: 'POST',
|
||||
body: new FormData(uploadForm),
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
var payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || 'Le téléversement a échoué.');
|
||||
}
|
||||
|
||||
setFeedback('Image téléversée. Rafraîchissement…', false);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : 'Le téléversement a échoué.', true);
|
||||
} finally {
|
||||
uploadButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadButton) {
|
||||
uploadButton.addEventListener('click', function () {
|
||||
handleUpload();
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('click', async function (event) {
|
||||
var button = event.target.closest('[data-media-action]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var action = button.dataset.mediaAction;
|
||||
var url = button.dataset.mediaUrl || '';
|
||||
var mediaId = Number(button.dataset.mediaId || '0');
|
||||
|
||||
if (action === 'insert-editor') {
|
||||
if (isPickerMode && window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'netslim:media-selected',
|
||||
url: url,
|
||||
mediaId: mediaId,
|
||||
html: buildImageHtml(url, mediaId)
|
||||
}, window.location.origin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'copy-url') {
|
||||
await copyToClipboard(toAbsoluteUrl(url));
|
||||
flashButtonLabel(button, 'URL copiée.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'copy-html') {
|
||||
await copyToClipboard(buildImageHtml(url, mediaId));
|
||||
flashButtonLabel(button, 'HTML copié.', false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
flashButtonLabel(button, 'Copie impossible.', true);
|
||||
setFeedback('Impossible de copier dans le presse-papiers.', true);
|
||||
}
|
||||
});
|
||||
})(window, document);
|
||||
147
assets/js/post-editor-media-picker.js
Normal file
147
assets/js/post-editor-media-picker.js
Normal file
@@ -0,0 +1,147 @@
|
||||
(function (window, document, $) {
|
||||
'use strict';
|
||||
|
||||
if (!$ || typeof $.fn.trumbowyg !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
var editorElement = document.getElementById('editor');
|
||||
var modal = document.getElementById('media-picker-modal');
|
||||
var closeButton = document.getElementById('media-picker-close');
|
||||
var frame = document.getElementById('media-picker-frame');
|
||||
|
||||
if (!editorElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $editor = $(editorElement);
|
||||
var previousActiveElement = null;
|
||||
|
||||
function escapeHtmlAttribute(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function createMediaHtml(url, mediaId) {
|
||||
return '<img src="' + escapeHtmlAttribute(url) + '" alt="" data-media-id="' + Number(mediaId) + '">';
|
||||
}
|
||||
|
||||
function ensurePickerLoaded() {
|
||||
if (!frame || !frame.dataset.pickerSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frame.getAttribute('src')) {
|
||||
frame.setAttribute('src', frame.dataset.pickerSrc);
|
||||
}
|
||||
}
|
||||
|
||||
function focusToolbarButton() {
|
||||
var button = document.querySelector('.trumbowyg-mediaPicker-button');
|
||||
if (button) {
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousActiveElement = document.activeElement;
|
||||
$editor.trumbowyg('saveRange');
|
||||
ensurePickerLoaded();
|
||||
modal.hidden = false;
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
modal.hidden = true;
|
||||
|
||||
if (previousActiveElement instanceof HTMLElement) {
|
||||
previousActiveElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
focusToolbarButton();
|
||||
}
|
||||
|
||||
function insertSelectedMedia(url, mediaId) {
|
||||
if (!url || !mediaId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$editor.trumbowyg('restoreRange');
|
||||
$editor.trumbowyg('execCmd', {
|
||||
cmd: 'insertHTML',
|
||||
param: createMediaHtml(url, mediaId)
|
||||
});
|
||||
closePicker();
|
||||
$editor.trumbowyg('saveRange');
|
||||
}
|
||||
|
||||
$editor.trumbowyg({
|
||||
lang: 'fr',
|
||||
removeformatPasted: true,
|
||||
btnsDef: {
|
||||
mediaPicker: {
|
||||
fn: function () {
|
||||
openPicker();
|
||||
return true;
|
||||
},
|
||||
ico: 'upload',
|
||||
title: 'Médiathèque'
|
||||
}
|
||||
},
|
||||
btns: [
|
||||
['viewHTML'],
|
||||
['formatting'],
|
||||
['strong', 'em', 'underline', 'del'],
|
||||
['link', 'mediaPicker'],
|
||||
['justifyLeft', 'justifyCenter', 'justifyRight'],
|
||||
['unorderedList', 'orderedList'],
|
||||
['insertHorizontalRule'],
|
||||
['fullscreen']
|
||||
]
|
||||
});
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', closePicker);
|
||||
}
|
||||
|
||||
if (modal) {
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closePicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && modal && !modal.hidden) {
|
||||
closePicker();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== window.location.origin || !event.data || event.data.type !== 'netslim:media-selected') {
|
||||
return;
|
||||
}
|
||||
|
||||
insertSelectedMedia(event.data.url, event.data.mediaId);
|
||||
});
|
||||
})(window, document, window.jQuery);
|
||||
16
assets/scss/base/_reset.scss
Normal file
16
assets/scss/base/_reset.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Reset / base globale
|
||||
// =============================================================
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: $font-family-base;
|
||||
font-size: $font-size-base;
|
||||
color: $color-text;
|
||||
margin: $spacing-xl;
|
||||
}
|
||||
113
assets/scss/base/_typography.scss
Normal file
113
assets/scss/base/_typography.scss
Normal file
@@ -0,0 +1,113 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Typographie — styles globaux du texte
|
||||
// =============================================================
|
||||
// Échelle typographique de référence pour les éléments HTML sémantiques.
|
||||
// Les composants surchargent ces valeurs si leur contexte l'exige
|
||||
// (ex: .card__title, .error-page__code définissent leur propre taille).
|
||||
// Les styles de contenu éditeur (Trumbowyg) sont dans components/_rich-text.scss.
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Titres
|
||||
// -------------------------------------------------------------
|
||||
// h1 : titre d'article (detail.twig) et logo du site (site-header__logo)
|
||||
// h2 : titres de pages et de sections (toutes les vues admin et auth)
|
||||
// h3 : sous-titres de blocs (admin-create__title)
|
||||
// h4-h6 : non utilisés dans les templates, définis en filet de sécurité
|
||||
// pour le contenu HTML inséré via Trumbowyg
|
||||
|
||||
h1 {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-bold;
|
||||
line-height: $line-height-tight;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-bold;
|
||||
line-height: $line-height-tight;
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-bold;
|
||||
line-height: $line-height-snug;
|
||||
margin: 0 0 $spacing-sm;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-bold;
|
||||
line-height: $line-height-base;
|
||||
margin: 0 0 $spacing-sm;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Liens
|
||||
// -------------------------------------------------------------
|
||||
// Liens nus sans classe BEM : navigation intra-page, liens de retour,
|
||||
// "Mot de passe oublié ?", liens du footer.
|
||||
// Les liens dans les composants (.card__title-link, .btn…) surchargent.
|
||||
|
||||
a {
|
||||
color: $color-primary;
|
||||
text-decoration: underline;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Éléments inline
|
||||
// -------------------------------------------------------------
|
||||
// small : hints de formulaire ("Minimum 12 caractères"), métadonnées d'articles
|
||||
small {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
// code : slugs dans l'admin (categories/index.twig), URLs dans les médias
|
||||
code {
|
||||
font-family: monospace, monospace;
|
||||
font-size: $font-size-sm;
|
||||
background: $color-bg-light;
|
||||
padding: $spacing-2xs 0.3em;
|
||||
border-radius: $radius-sm;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// pre : blocs de code dans le contenu Trumbowyg
|
||||
pre {
|
||||
font-family: monospace, monospace;
|
||||
font-size: $font-size-sm;
|
||||
background: $color-bg-light;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
overflow-x: auto;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Séparateurs
|
||||
// -------------------------------------------------------------
|
||||
// hr : séparateur dans detail.twig (après l'article) et form.twig (avant les métadonnées)
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid $color-border;
|
||||
margin: $spacing-lg 0;
|
||||
}
|
||||
57
assets/scss/components/_admin-create.scss
Normal file
57
assets/scss/components/_admin-create.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Boîte d'ajout (admin)
|
||||
// =============================================================
|
||||
// Bloc réutilisable pour les encarts "Ajouter …" des pages admin.
|
||||
// Inspiré du style historique de l'écran /admin/categories.
|
||||
|
||||
.admin-create {
|
||||
margin-bottom: $spacing-xl;
|
||||
padding: $spacing-md;
|
||||
background: $color-bg-light;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.admin-create__title {
|
||||
margin: 0 0 $spacing-md;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.admin-create__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-create__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.admin-create__input {
|
||||
min-width: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-create__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.admin-create__hint {
|
||||
margin: $spacing-sm 0 0;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.admin-create__feedback {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
24
assets/scss/components/_alert.scss
Normal file
24
assets/scss/components/_alert.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Alertes / messages flash
|
||||
// =============================================================
|
||||
|
||||
.alert {
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $spacing-md;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&--danger {
|
||||
background: $color-danger-bg;
|
||||
border-color: $color-danger-border;
|
||||
color: $color-danger-text;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: $color-success-bg;
|
||||
border-color: $color-success-border;
|
||||
color: $color-success-text;
|
||||
}
|
||||
}
|
||||
52
assets/scss/components/_badge.scss
Normal file
52
assets/scss/components/_badge.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Badges — bloc composant .badge pour rôles utilisateur et catégories
|
||||
// =============================================================
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-2xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
line-height: $line-height-tight;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.badge--admin {
|
||||
color: $color-warning-text;
|
||||
background: $color-warning-bg;
|
||||
}
|
||||
|
||||
.badge--editor {
|
||||
color: $color-info-text;
|
||||
background: $color-info-bg;
|
||||
}
|
||||
|
||||
.badge--user {
|
||||
color: $color-text-muted;
|
||||
background: $color-bg-light;
|
||||
}
|
||||
|
||||
// Badge catégorie — affiché sur les cartes d'articles et les pages de détail
|
||||
// Cliquable : redirige vers la liste filtrée par catégorie
|
||||
.badge--category {
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
vertical-align: middle;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
background: $color-primary-bg-hover;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
100
assets/scss/components/_button.scss
Normal file
100
assets/scss/components/_button.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// =============================================================
|
||||
// Boutons — bloc composant .btn
|
||||
// =============================================================
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
line-height: $line-height-tight;
|
||||
font: inherit;
|
||||
font-weight: $font-weight-semibold;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&--disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: $color-primary;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
background: color.scale($color-primary, $lightness: -14%);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: $color-text-muted;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
background: color.scale($color-text-muted, $lightness: -14%);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: $color-danger;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
background: color.scale($color-danger, $lightness: -12%);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
// Modificateur taille — boutons principaux dans les formulaires centrés
|
||||
.btn--lg {
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
}
|
||||
|
||||
// Modificateur largeur — occupe toute la largeur de son conteneur
|
||||
.btn--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Variante de lien textuel.
|
||||
// Utiliser la combinaison BEM `.btn.btn--link`.
|
||||
.btn--link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $color-primary;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
line-height: $line-height-base;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $focus-ring-color;
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
197
assets/scss/components/_card.scss
Normal file
197
assets/scss/components/_card.scss
Normal file
@@ -0,0 +1,197 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Composant carte — générique
|
||||
// =============================================================
|
||||
// Définit deux blocs BEM :
|
||||
// .card-list — conteneur de liste de cartes
|
||||
// .card — carte individuelle (vignette + contenu)
|
||||
// Aucune référence au domaine métier : les contenus spécifiques
|
||||
// (extrait d'article, prix produit…) sont surchargés dans les
|
||||
// fichiers de module (modules/post/_listing.scss, etc.).
|
||||
|
||||
// Conteneur de liste de cartes
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
// Modificateur : fond grisé encadrant les cartes (ex: page d'accueil)
|
||||
// Rend les box-shadow perceptibles en créant un contraste avec le fond blanc des cartes
|
||||
.card-list--contained {
|
||||
background: $color-bg-light;
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// Carte : vignette à gauche, corps à droite
|
||||
// La vignette est toujours présente (image ou initiales)
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-lg;
|
||||
padding: $spacing-lg;
|
||||
background: $color-bg-white;
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 1px 4px $color-card-shadow;
|
||||
}
|
||||
|
||||
// Lien englobant la vignette — pas de décoration, tabindex=-1 pour
|
||||
// éviter la double tabulation (le titre est déjà un lien cliquable)
|
||||
.card__thumb-link {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
}
|
||||
|
||||
// Vignette image
|
||||
.card__thumb {
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: $radius-md;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Vignette initiales (affiché quand l'entité n'a pas d'image)
|
||||
// Mêmes dimensions que .card__thumb pour un alignement cohérent
|
||||
.card__initials {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-initials;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-display;
|
||||
font-weight: $font-weight-bold;
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Wrapper externe : prend la place à droite de la vignette
|
||||
// Organisé en flex colonne pour empiler .card__body et .card__actions
|
||||
.card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// Partie textuelle de la carte
|
||||
// max-height contraint le titre + métadonnées + aperçu à la hauteur de la vignette.
|
||||
// overflow:hidden clip l'aperçu si le contenu dépasse — .card__actions est en dehors
|
||||
// de ce conteneur et reste donc toujours visible.
|
||||
.card__body {
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Titre de la carte
|
||||
.card__title {
|
||||
margin: 0 0 $spacing-xs;
|
||||
font-size: $font-size-xl;
|
||||
}
|
||||
|
||||
// Lien du titre — élément BEM dédié plutôt qu'un sélecteur descendant sur <a>
|
||||
.card__title-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// Métadonnées (date, auteur, prix…)
|
||||
.card__meta {
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
// Texte court de présentation — clipé par overflow:hidden du parent .card__body.
|
||||
// word-break empêche le débordement de mots longs ou d'URLs sans espace.
|
||||
.card__excerpt {
|
||||
margin: $spacing-sm 0;
|
||||
color: $color-text;
|
||||
line-height: $line-height-base;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
// Styles pour le HTML formaté retourné par post_excerpt()
|
||||
// Les listes à puces/numérotées sont compactées pour rester dans l'aperçu
|
||||
ul,
|
||||
ol {
|
||||
margin: $spacing-xs 0 0 $spacing-sm;
|
||||
padding-left: $spacing-sm;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
em,
|
||||
i {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Zone d'actions (liens, boutons) — aspect défini dans le fichier de page
|
||||
.card__actions {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
// Adaptation mobile : vignette au-dessus du corps, pleine largeur
|
||||
// max-height est annulé sur .card__body — en disposition colonne l'aperçu
|
||||
// peut s'étendre librement sous la vignette.
|
||||
@include mobile {
|
||||
.card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card__thumb-link {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card__thumb,
|
||||
.card__initials {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
// En mobile (disposition colonne), le corps s'étend librement
|
||||
.card__body {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// Titre légèrement réduit pour éviter qu'il soit trop imposant sur petit écran
|
||||
.card__title {
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
}
|
||||
26
assets/scss/components/_empty-state.scss
Normal file
26
assets/scss/components/_empty-state.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
.empty-state {
|
||||
padding: $spacing-lg;
|
||||
border: 1px dashed $color-border;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-light;
|
||||
|
||||
&__title {
|
||||
margin: 0 0 $spacing-xs;
|
||||
line-height: $line-height-snug;
|
||||
}
|
||||
|
||||
&__message {
|
||||
margin: 0;
|
||||
color: $color-text-muted;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
}
|
||||
119
assets/scss/components/_form-container.scss
Normal file
119
assets/scss/components/_form-container.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Formulaires
|
||||
// =============================================================
|
||||
// Convention retenue :
|
||||
// - .form-container est un bloc métier-agnostique réutilisable
|
||||
// - les utilitaires de layout vivent dans utilities/_inline.scss
|
||||
|
||||
.form-container {
|
||||
max-width: $layout-content-max-width;
|
||||
margin: 0 auto;
|
||||
|
||||
&__panel {
|
||||
background: $color-bg-white;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&__header {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
margin: $spacing-sm 0 0;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__field--editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: $spacing-xs 0 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__input,
|
||||
&__select,
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-white;
|
||||
font: inherit;
|
||||
font-weight: $font-weight-regular;
|
||||
line-height: $line-height-base;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
min-height: 14rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
align-items: center;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
&__action {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin-top: $spacing-lg;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__input--disabled {
|
||||
background: $color-bg-light;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container--narrow {
|
||||
max-width: 420px;
|
||||
margin: $spacing-xl auto;
|
||||
}
|
||||
59
assets/scss/components/_media-picker-modal.scss
Normal file
59
assets/scss/components/_media-picker-modal.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Media picker modal — fenêtre de sélection des médias (Trumbowyg)
|
||||
// =============================================================
|
||||
|
||||
.media-picker-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-lg;
|
||||
// Overlay plus sombre que la charte "card" pour bien isoler le focus.
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.media-picker-modal.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.media-picker-modal__dialog {
|
||||
width: min(1100px, 100%);
|
||||
height: min(820px, calc(100vh - #{$spacing-2xl}));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: $color-bg-white;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-picker-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md ($spacing-md + $spacing-xs);
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
.media-picker-modal__title {
|
||||
margin: 0;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.media-picker-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: $color-bg-light;
|
||||
}
|
||||
|
||||
.media-picker-modal__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: $color-bg-white;
|
||||
}
|
||||
86
assets/scss/components/_pagination.scss
Normal file
86
assets/scss/components/_pagination.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Pagination
|
||||
// =============================================================
|
||||
// Bloc partagé car rendu via un partial Twig utilisé sur plusieurs écrans.
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.pagination__summary {
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
.pagination__pages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.pagination__link,
|
||||
.pagination__control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
text-decoration: none;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination__link {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
color: $color-text;
|
||||
background: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
border-color: $color-primary;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&--current {
|
||||
border-color: $color-primary;
|
||||
color: $color-primary;
|
||||
background: $color-primary-bg;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination__control--disabled {
|
||||
color: $color-text-subtle;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.pagination {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pagination__summary,
|
||||
.pagination__control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination__pages {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
82
assets/scss/components/_rich-text.scss
Normal file
82
assets/scss/components/_rich-text.scss
Normal file
@@ -0,0 +1,82 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Contenu riche partagé
|
||||
// =============================================================
|
||||
// Exception assumée au BEM : le HTML interne est généré par Trumbowyg et
|
||||
// n'est pas entièrement piloté par les templates Twig. Les sélecteurs
|
||||
// descendants sont donc réservés à .rich-text et à .trumbowyg-editor.
|
||||
|
||||
.rich-text,
|
||||
.trumbowyg-editor {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
p, ul, ol, blockquote, pre {
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: $spacing-md;
|
||||
border-left: 3px solid $color-border;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: $radius-md;
|
||||
margin: $spacing-sm 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid $color-border;
|
||||
padding: $spacing-sm;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.trumbowyg-box,
|
||||
.trumbowyg-editor,
|
||||
.trumbowyg-button-pane {
|
||||
font: inherit;
|
||||
font-weight: $font-weight-regular;
|
||||
}
|
||||
|
||||
.trumbowyg-box,
|
||||
.trumbowyg-editor {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.trumbowyg-box {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: $radius-md;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-within {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.trumbowyg-button-pane button {
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $focus-ring-color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
89
assets/scss/components/_search-bar.scss
Normal file
89
assets/scss/components/_search-bar.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// =============================================================
|
||||
// Barre de recherche — bloc .search-bar
|
||||
// =============================================================
|
||||
// Bloc autonome réutilisable pour les listes filtrables.
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.search-bar__input {
|
||||
flex: 1;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
font: inherit;
|
||||
line-height: $line-height-base;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar__btn {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: $color-primary;
|
||||
color: $color-bg-white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-md;
|
||||
font: inherit;
|
||||
font-weight: $font-weight-semibold;
|
||||
line-height: $line-height-tight;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
background: color.scale($color-primary, $lightness: -16%);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar__reset {
|
||||
color: $color-text-muted;
|
||||
text-decoration: none;
|
||||
font-size: $font-size-sm;
|
||||
padding: $spacing-xs;
|
||||
line-height: $line-height-none;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar__info {
|
||||
margin: (-$spacing-sm) 0 $spacing-md;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-bar__btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
79
assets/scss/components/_upload.scss
Normal file
79
assets/scss/components/_upload.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Bloc upload — aperçu et actions sur les médias uploadés
|
||||
// =============================================================
|
||||
|
||||
.upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.upload__thumb-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.upload__thumb {
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $color-border;
|
||||
background: $color-bg-light;
|
||||
}
|
||||
|
||||
.upload__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload__url {
|
||||
display: block;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-muted;
|
||||
margin-bottom: $spacing-xs;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload__thumb-link--compact {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.upload__thumb--compact {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
// Variante bouton — utile en mode picker si on veut rendre la vignette cliquable
|
||||
.upload__thumb-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
32
assets/scss/core/_mixins.scss
Normal file
32
assets/scss/core/_mixins.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@use "variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Mixins
|
||||
// =============================================================
|
||||
|
||||
// Breakpoint mobile — au-dessous de cette largeur, les composants
|
||||
// basculent en layout colonne (ex: .card)
|
||||
@mixin mobile {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions interactives cohérentes sur les composants cliquables
|
||||
@mixin interactive-transition {
|
||||
transition:
|
||||
color $transition-fast,
|
||||
background-color $transition-fast,
|
||||
border-color $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
opacity $transition-fast,
|
||||
transform $transition-fast,
|
||||
text-decoration-color $transition-fast;
|
||||
}
|
||||
|
||||
// Ring de focus accessible et homogène
|
||||
@mixin focus-ring {
|
||||
outline: none;
|
||||
border-color: $color-primary;
|
||||
box-shadow: 0 0 0 $focus-ring-width $focus-ring-color;
|
||||
}
|
||||
104
assets/scss/core/_variables.scss
Normal file
104
assets/scss/core/_variables.scss
Normal file
@@ -0,0 +1,104 @@
|
||||
// =============================================================
|
||||
// Variables — design tokens du projet
|
||||
// =============================================================
|
||||
// Ce fichier est la source de vérité pour toutes les valeurs
|
||||
// de couleur, typographie et espacement utilisées dans le projet.
|
||||
// Modifier une valeur ici la propage à l'ensemble des composants.
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Couleurs principales
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$color-primary: #007bff;
|
||||
$color-primary-bg: #cce5ff;
|
||||
$color-primary-bg-hover: #b8daff;
|
||||
$color-danger: #dc3545;
|
||||
$color-success-bg: #d4edda;
|
||||
$color-success-border: #c3e6cb;
|
||||
$color-success-text: #155724;
|
||||
$color-danger-bg: #f8d7da;
|
||||
$color-danger-border: #f5c6cb;
|
||||
$color-danger-text: #721c24;
|
||||
$color-warning-bg: #fff3cd;
|
||||
$color-warning-text: #856404;
|
||||
$color-info-bg: #d1ecf1;
|
||||
$color-info-text: #0c5460;
|
||||
$color-card-shadow: rgba(0, 0, 0, 0.07);
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Couleurs neutres
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$color-text: #212529;
|
||||
$color-text-muted: #6c757d;
|
||||
$color-text-subtle: #aaa;
|
||||
$color-bg-light: #f8f9fa;
|
||||
$color-bg-white: #ffffff;
|
||||
$color-bg-initials: #e9ecef;
|
||||
$color-border: #dee2e6;
|
||||
$color-border-light: #ccc;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Typographie
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$font-family-base: Arial, sans-serif;
|
||||
$font-size-xs: 0.85rem;
|
||||
$font-size-sm: 0.875rem;
|
||||
$font-size-base: 1rem;
|
||||
$font-size-md: 1.05rem;
|
||||
$font-size-lg: 1.15rem;
|
||||
$font-size-xl: 1.2rem;
|
||||
$font-size-2xl: 1.4rem;
|
||||
$font-size-3xl: 1.75rem;
|
||||
$font-size-display: 2rem;
|
||||
$font-size-display-lg: 4rem;
|
||||
$font-size-footer: 0.9rem;
|
||||
$line-height-none: 1;
|
||||
$line-height-tight: 1.2;
|
||||
$line-height-snug: 1.3;
|
||||
$line-height-base: 1.5;
|
||||
$font-weight-regular: 400;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Espacements
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$spacing-2xs: 0.125rem;
|
||||
$spacing-xs: 0.25rem;
|
||||
$spacing-sm: 0.5rem;
|
||||
$spacing-md: 1rem;
|
||||
$spacing-lg: 1.5rem;
|
||||
$spacing-xl: 2rem;
|
||||
$spacing-2xl: 3rem;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Bordures
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$radius-sm: 3px;
|
||||
$radius-md: 4px;
|
||||
$radius-lg: 8px;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// États interactifs
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$focus-ring-width: 2px;
|
||||
$focus-ring-color: rgba(0, 123, 255, 0.16);
|
||||
$transition-fast: 0.15s ease;
|
||||
$transition-base: 0.2s ease;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layout
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$layout-content-max-width: 960px;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Responsive
|
||||
// -------------------------------------------------------------
|
||||
|
||||
$breakpoint-mobile: 600px;
|
||||
19
assets/scss/layout/_picker-layout.scss
Normal file
19
assets/scss/layout/_picker-layout.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Layout picker — pages embarquées (ex: sélecteur de médias)
|
||||
// =============================================================
|
||||
|
||||
body.picker-layout {
|
||||
margin: 0;
|
||||
background: $color-bg-light;
|
||||
}
|
||||
|
||||
.picker-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.picker-layout__inner {
|
||||
padding: $spacing-md;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
13
assets/scss/layout/_site-footer.scss
Normal file
13
assets/scss/layout/_site-footer.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Footer
|
||||
// =============================================================
|
||||
|
||||
.site-footer {
|
||||
margin-top: $spacing-xl;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid $color-border-light;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-footer;
|
||||
}
|
||||
88
assets/scss/layout/_site-header.scss
Normal file
88
assets/scss/layout/_site-header.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
@use "../core/variables" as *;
|
||||
@use "../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Header
|
||||
// =============================================================
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid $color-border-light;
|
||||
padding: $spacing-md 0;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-header__logo {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Lien englobant le titre du blog — élément BEM dédié plutôt qu'un sélecteur descendant sur <a>
|
||||
.site-header__logo-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.site-header__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
// Nom d'utilisateur connecté dans le header
|
||||
.site-header__user {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
// Élément d'action cliquable dans le header (lien ou bouton)
|
||||
.site-header__action {
|
||||
text-decoration: underline;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: $color-primary;
|
||||
font: inherit;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $focus-ring-color;
|
||||
outline-offset: 2px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.site-header__inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.site-header__nav {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
17
assets/scss/layout/_site-main.scss
Normal file
17
assets/scss/layout/_site-main.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Main
|
||||
// =============================================================
|
||||
|
||||
.site-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-main__inner {
|
||||
width: 100%;
|
||||
max-width: $layout-content-max-width;
|
||||
margin: 0 auto;
|
||||
padding-inline: $spacing-md;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
43
assets/scss/main.scss
Normal file
43
assets/scss/main.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
// =============================================================
|
||||
// Point d'entrée — importe tous les partiels dans l'ordre
|
||||
// =============================================================
|
||||
// Règle d'organisation frontend :
|
||||
// - core : design tokens et mixins, jamais de CSS généré
|
||||
// - base : HTML global (reset, typographie native)
|
||||
// - components : blocs BEM réutilisables et exceptions contrôlées
|
||||
// - layout : structure globale de page (header, main, footer)
|
||||
// - modules : styles propres à un domaine ou à un écran métier
|
||||
// - utilities : helpers ponctuels préfixés .u-
|
||||
//
|
||||
// Ce fichier reste un simple orchestrateur d'imports.
|
||||
|
||||
@use "core/variables" as *;
|
||||
@use "core/mixins" as *;
|
||||
|
||||
@use "base/reset";
|
||||
@use "base/typography";
|
||||
|
||||
@use "components/button";
|
||||
@use "components/alert";
|
||||
@use "components/badge";
|
||||
@use "components/card";
|
||||
@use "components/empty-state";
|
||||
@use "components/admin-create";
|
||||
@use "components/form-container";
|
||||
@use "components/search-bar";
|
||||
@use "components/upload";
|
||||
@use "components/pagination";
|
||||
@use "components/rich-text";
|
||||
@use "components/media-picker-modal";
|
||||
|
||||
@use "layout/site-header";
|
||||
@use "layout/site-main";
|
||||
@use "layout/picker-layout";
|
||||
@use "layout/site-footer";
|
||||
|
||||
@use "modules/shared/admin" as admin-shared;
|
||||
@use "modules/shared/error-page";
|
||||
@use "modules/post/listing";
|
||||
@use "modules/post/post";
|
||||
|
||||
@use "utilities/inline";
|
||||
69
assets/scss/modules/post/_listing.scss
Normal file
69
assets/scss/modules/post/_listing.scss
Normal file
@@ -0,0 +1,69 @@
|
||||
@use "../../core/variables" as *;
|
||||
@use "../../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Page d'accueil — liste des articles
|
||||
// =============================================================
|
||||
// Les styles de structure des cartes sont dans components/_card.scss.
|
||||
// Ce fichier surcharge uniquement les éléments spécifiques au contexte blog.
|
||||
|
||||
// Lien d'action "Lire la suite →" — élément BEM dédié plutôt qu'un sélecteur
|
||||
// descendant sur <a>, pour respecter BEM et éviter les collisions de styles
|
||||
.card__actions-link {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-primary;
|
||||
text-decoration: none;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Barre de filtre par catégorie
|
||||
// =============================================================
|
||||
|
||||
// Conteneur de la liste de liens de filtre
|
||||
.category-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
border-bottom: 1px solid $color-border;
|
||||
}
|
||||
|
||||
// Lien de filtre individuel
|
||||
.category-filter__item {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
text-decoration: none;
|
||||
color: $color-text-muted;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
border-color: $color-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
// État actif — catégorie sélectionnée
|
||||
.category-filter__item--active {
|
||||
color: $color-primary;
|
||||
border-color: $color-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
29
assets/scss/modules/post/_post.scss
Normal file
29
assets/scss/modules/post/_post.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@use "../../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Bloc article — page de détail
|
||||
// =============================================================
|
||||
// Ce fichier ne porte que la coque du bloc .post.
|
||||
// Les styles de contenu riche partagé (HTML Trumbowyg rendu en front
|
||||
// et zone d'édition WYSIWYG) vivent dans components/_rich-text.scss.
|
||||
|
||||
.post {
|
||||
padding: $spacing-md 0;
|
||||
|
||||
&__title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__updated {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.post__back {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
161
assets/scss/modules/shared/_admin.scss
Normal file
161
assets/scss/modules/shared/_admin.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
@use "../../core/variables" as *;
|
||||
@use "../../core/mixins" as *;
|
||||
|
||||
// =============================================================
|
||||
// Administration partagée
|
||||
// =============================================================
|
||||
// Blocs BEM utilisés sur plusieurs écrans d'administration.
|
||||
|
||||
.admin-nav {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
&__body {
|
||||
display: grid;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__intro {
|
||||
margin: 0;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: $spacing-sm;
|
||||
border-bottom: 1px solid $color-border;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
th {
|
||||
background: $color-bg-light;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
&__self {
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
color: $color-text-subtle;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin: $spacing-xs 0 0;
|
||||
padding-left: $spacing-md;
|
||||
}
|
||||
|
||||
&__list-item + &__list-item {
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
font-size: $font-size-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
background: $color-bg-white;
|
||||
cursor: pointer;
|
||||
|
||||
@include interactive-transition;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus-ring;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.admin-table {
|
||||
display: block;
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody,
|
||||
tr {
|
||||
display: block;
|
||||
}
|
||||
|
||||
tr {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $spacing-md;
|
||||
padding: $spacing-sm;
|
||||
background: $color-bg-white;
|
||||
}
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-xs 0;
|
||||
border-bottom: 1px solid $color-border;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: attr(data-label);
|
||||
font-weight: $font-weight-semibold;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
21
assets/scss/modules/shared/_error-page.scss
Normal file
21
assets/scss/modules/shared/_error-page.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
@use "../../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Page d'erreur partagée
|
||||
// =============================================================
|
||||
|
||||
.error-page {
|
||||
text-align: center;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.error-page__code {
|
||||
font-size: $font-size-display-lg;
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.error-page__message {
|
||||
font-size: $font-size-xl;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
19
assets/scss/utilities/_inline.scss
Normal file
19
assets/scss/utilities/_inline.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
@use "../core/variables" as *;
|
||||
|
||||
// =============================================================
|
||||
// Utilitaires
|
||||
// =============================================================
|
||||
// Préfixe obligatoire : .u-
|
||||
// Utiliser seulement pour des ajustements de layout ponctuels, jamais
|
||||
// pour porter un style visuel métier ou remplacer un bloc BEM.
|
||||
|
||||
.u-inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.u-inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
15
bin/provision.php
Normal file
15
bin/provision.php
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Bootstrap;
|
||||
use Netig\Netslim\Kernel\Persistence\Infrastructure\Provisioner;
|
||||
|
||||
$bootstrap = Bootstrap::create(dirname(__DIR__), dirname(__DIR__));
|
||||
$container = $bootstrap->initializeInfrastructure();
|
||||
Provisioner::run($container->get(\PDO::class));
|
||||
|
||||
fwrite(STDOUT, "Provisioning termine.\n");
|
||||
7
bootstrap.php
Normal file
7
bootstrap.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Netig\Netslim\Kernel\Runtime\Bootstrap;
|
||||
|
||||
return Bootstrap::create(__DIR__, __DIR__);
|
||||
67
composer.json
Normal file
67
composer.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "netig/netslim-blog",
|
||||
"description": "Concrete blog application built on top of netslim-core.",
|
||||
"license": "MIT",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"netig/netslim-core": "^1.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"friendsofphp/php-cs-fixer": "^3.50"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "@php vendor/bin/phpunit",
|
||||
"test:coverage": "@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-text",
|
||||
"stan": "@php vendor/bin/phpstan analyse",
|
||||
"cs:check": "@php vendor/bin/php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "@php vendor/bin/php-cs-fixer fix",
|
||||
"qa": [
|
||||
"@test",
|
||||
"@stan",
|
||||
"@cs:check"
|
||||
],
|
||||
"provision": "@php bin/provision.php",
|
||||
"start": "@php -S 127.0.0.1:8080 -t public",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"test": "Run PHPUnit test suite",
|
||||
"test:coverage": "Run PHPUnit with text coverage output (requires Xdebug)",
|
||||
"stan": "Run PHPStan static analysis",
|
||||
"cs:check": "Check coding style with PHP CS Fixer",
|
||||
"cs:fix": "Fix coding style issues with PHP CS Fixer",
|
||||
"qa": "Run quality checks",
|
||||
"provision": "Provision the development SQLite database",
|
||||
"start": "Start the local development server",
|
||||
"frontend:install": "Install frontend dependencies",
|
||||
"frontend:build": "Build frontend assets"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://git.netig.net/netig/netslim-core.git"
|
||||
}
|
||||
],
|
||||
"prefer-stable": true
|
||||
}
|
||||
25
config/modules.php
Normal file
25
config/modules.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Post\PostModule;
|
||||
use App\Site\SiteModule;
|
||||
use Netig\Netslim\AuditLog\AuditLogModule;
|
||||
use Netig\Netslim\Identity\IdentityModule;
|
||||
use Netig\Netslim\Kernel\Runtime\KernelModule;
|
||||
use Netig\Netslim\Media\MediaModule;
|
||||
use Netig\Netslim\Notifications\NotificationsModule;
|
||||
use Netig\Netslim\Settings\SettingsModule;
|
||||
use Netig\Netslim\Taxonomy\TaxonomyModule;
|
||||
|
||||
return [
|
||||
KernelModule::class,
|
||||
IdentityModule::class,
|
||||
SettingsModule::class,
|
||||
AuditLogModule::class,
|
||||
NotificationsModule::class,
|
||||
TaxonomyModule::class,
|
||||
MediaModule::class,
|
||||
SiteModule::class,
|
||||
PostModule::class,
|
||||
];
|
||||
76
docker-compose.yml
Normal file
76
docker-compose.yml
Normal file
@@ -0,0 +1,76 @@
|
||||
services:
|
||||
app:
|
||||
image: netslim-app:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Répertoire de travail de l'entrypoint : reçoit public/ compilé,
|
||||
# puis partagé avec Nginx via le mount ci-dessous.
|
||||
- ./data:/data
|
||||
|
||||
# Base SQLite et migrations : persistés entre redéploiements.
|
||||
- ./data/database:/var/www/app/database
|
||||
|
||||
# Cache Twig/HTMLPurifier et logs : persistés entre redémarrages.
|
||||
- ./data/var:/var/www/app/var
|
||||
|
||||
# Uploads : PHP écrit dans public/media/, Nginx le sert en lecture seule.
|
||||
- ./data/public/media:/var/www/app/public/media
|
||||
|
||||
# phpdotenv requiert un fichier physique sur le disque (Dotenv::createImmutable) ;
|
||||
# `env_file` seul ne suffit pas car il injecte uniquement dans l'environnement
|
||||
# du process sans créer de fichier accessible via file_get_contents.
|
||||
- ./.env:/var/www/app/.env:ro
|
||||
|
||||
# Rend les variables accessibles via getenv() / $_SERVER en dehors de phpdotenv
|
||||
# (scripts CLI, healthchecks…).
|
||||
env_file: .env
|
||||
|
||||
# Vérifie que PHP-FPM écoute sur le port 9000 avant de déclarer le service sain.
|
||||
environment:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-*}
|
||||
|
||||
# bash /dev/tcp est disponible sur l'image Debian php:8.4-fpm sans dépendance
|
||||
# supplémentaire. start_period laisse le temps à entrypoint.sh de terminer
|
||||
# (sync public/, permissions, caches) avant que les échecs ne comptent.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/9000'"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
nginx:
|
||||
image: nginx:stable
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8888:80"
|
||||
volumes:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# Fichiers statiques servis directement par Nginx sans passer par PHP.
|
||||
- ./data/public:/var/www/app/public:ro
|
||||
|
||||
provision:
|
||||
image: netslim-app:latest
|
||||
profiles: ["manual"]
|
||||
# Réutilise exactement la même image que `app` pour éviter tout écart entre
|
||||
# le runtime HTTP et le runtime de provisionnement.
|
||||
# Même environnement/fichiers que le runtime PHP pour provisionner exactement
|
||||
# la même base SQLite persistée.
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./data/database:/var/www/app/database
|
||||
- ./data/var:/var/www/app/var
|
||||
- ./data/public/media:/var/www/app/public/media
|
||||
- ./.env:/var/www/app/.env:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-*}
|
||||
restart: "no"
|
||||
command: ["php", "bin/provision.php"]
|
||||
54
docker/nginx/default.conf
Normal file
54
docker/nginx/default.conf
Normal file
@@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/app/public;
|
||||
index index.php;
|
||||
|
||||
# ── En-têtes de sécurité HTTP ────────────────────────────────────────────
|
||||
# Empêche le chargement de la page dans une iframe (clickjacking)
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# Désactive le sniffing MIME : le navigateur respecte le Content-Type déclaré
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
# Limite les informations transmises dans le Referer aux pages externes
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# Désactive les fonctionnalités navigateur non utilisées
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Fichiers statiques servis directement par Nginx, sans passer par PHP.
|
||||
# expires 1y active le cache navigateur longue durée.
|
||||
location ~* \.(css|js|ico|png|jpg|jpeg|gif|svg|webp|woff2)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Bloquer l'exécution de PHP dans le répertoire des uploads.
|
||||
location ~* /media/.*\.php$ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Front controller Slim : toute URL sans fichier correspondant
|
||||
# est renvoyée vers index.php pour être traitée par le routeur.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php$is_args$args;
|
||||
}
|
||||
|
||||
# PHP-FPM via réseau Docker interne (port 9000 par défaut).
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
|
||||
# Transmet les en-têtes du reverse proxy amont (Caddy hôte, etc.)
|
||||
# pour que l'application connaisse l'IP réelle du client et le protocole.
|
||||
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
|
||||
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
# Bloquer l'accès aux fichiers sensibles.
|
||||
location ~ /\.(env|git|htaccess) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
44
docker/php/Dockerfile
Normal file
44
docker/php/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# ── Stage 1 : compilation des assets ────────────────────────────────────────
|
||||
FROM node:20-slim AS assets
|
||||
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json ./
|
||||
# Layer séparé : npm ci n'est relancé que si package-lock.json change.
|
||||
# npm run build est dans un layer distinct : toute modification dans assets/
|
||||
# invalide uniquement ce layer, sans réinstaller les packages.
|
||||
RUN npm ci
|
||||
COPY assets/ assets/
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2 : image PHP de production ───────────────────────────────────────
|
||||
FROM php:8.4-fpm
|
||||
|
||||
# Extensions système et PHP dans un seul layer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsqlite3-dev libxml2-dev libonig-dev \
|
||||
libpng-dev libjpeg62-turbo-dev libwebp-dev unzip \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-configure gd --with-webp --with-jpeg \
|
||||
&& docker-php-ext-install pdo_sqlite mbstring dom fileinfo gd
|
||||
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www/app
|
||||
|
||||
# Dépendances Composer — layer mis en cache tant que les lock files n'ont pas changé
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# Code source + assets compilés depuis le stage 1
|
||||
COPY . .
|
||||
COPY --from=assets /build/public/assets/ public/assets/
|
||||
|
||||
# Archive les migrations hors du WORKDIR : le bind mount ./data/database/
|
||||
# écrase /var/www/app/database/ au démarrage — l'entrypoint recopie depuis ici.
|
||||
RUN cp -r database /database.baked
|
||||
|
||||
COPY --chmod=755 docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["php-fpm"]
|
||||
42
docker/php/entrypoint.sh
Normal file
42
docker/php/entrypoint.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# public/ → assets compilés, index.php
|
||||
# Synchronisé à chaque démarrage pour déployer les nouveaux assets.
|
||||
# media/ est exclu : c'est un bind mount séparé contenant les uploads
|
||||
# utilisateurs — le copier dans sa propre destination causerait une erreur.
|
||||
for item in /var/www/app/public/*; do
|
||||
name=$(basename "$item")
|
||||
[ "$name" = "media" ] && continue
|
||||
cp -a "$item" /data/public/
|
||||
done
|
||||
|
||||
# database/ → migrations depuis l'archive baked.
|
||||
# /var/www/app/database/ est un bind mount vide au premier démarrage :
|
||||
# les migrations sont copiées depuis /database.baked/ qui n'est pas monté.
|
||||
# cp -rn préserve app.sqlite sur les déploiements suivants.
|
||||
cp -rn /database.baked/. /var/www/app/database/ 2>/dev/null || true
|
||||
|
||||
# Pré-création de public/media/ pour que les permissions soient fixées
|
||||
# ici, en même temps que les autres répertoires persistants, plutôt qu'à
|
||||
# la première requête par Bootstrap::checkDirectories().
|
||||
mkdir -p /var/www/app/public/media
|
||||
|
||||
# Permissions sur les bind mounts : doit s'exécuter en root avant
|
||||
# le démarrage de PHP-FPM. Bootstrap.php crée ensuite les sous-répertoires
|
||||
# (var/cache/twig, var/cache/htmlpurifier, var/logs) à la première requête
|
||||
# avec les bonnes permissions.
|
||||
chown -R www-data:www-data /data /var/www/app/database /var/www/app/var /var/www/app/public/media
|
||||
|
||||
# Invalider les caches compilés à chaque déploiement.
|
||||
# - Twig : les templates compilés peuvent être obsolètes après modification d'une vue
|
||||
# ou d'une extension Twig.
|
||||
# - DI : le container PHP-DI compilé doit être regénéré après tout changement
|
||||
# dans src/Kernel/Runtime/DI/container.php. Sans cette ligne, la première requête compile
|
||||
# un container incohérent et toutes les suivantes échouent avec une erreur 500.
|
||||
rm -rf /var/www/app/var/cache/twig/*
|
||||
rm -rf /var/www/app/var/cache/di/*
|
||||
|
||||
# Passe la main à la commande principale (php-fpm) en remplaçant le processus
|
||||
# entrypoint par php-fpm PID 1 — requis pour la gestion des signaux Docker.
|
||||
exec "$@"
|
||||
14
docker/php/php.ini
Normal file
14
docker/php/php.ini
Normal file
@@ -0,0 +1,14 @@
|
||||
; Limites upload — doivent être cohérentes avec UPLOAD_MAX_SIZE dans .env.
|
||||
upload_max_filesize = 6M
|
||||
post_max_size = 8M
|
||||
|
||||
; Ne pas exposer la version PHP dans l'en-tête X-Powered-By
|
||||
expose_php = Off
|
||||
|
||||
; Remonte les erreurs PHP vers Docker
|
||||
log_errors = On
|
||||
error_log = /dev/stderr
|
||||
|
||||
; Renommer le cookie de session pour éviter le fingerprint PHP (PHPSESSID par défaut)
|
||||
; La valeur doit être synchronisée avec session_name() dans public/index.php si modifiée.
|
||||
session.name = sid
|
||||
18
docs/APPLICATION.md
Normal file
18
docs/APPLICATION.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Application blog
|
||||
|
||||
Ce dépôt porte une seule application : le blog.
|
||||
|
||||
Les fichiers applicatifs se trouvent directement à la racine du projet :
|
||||
- `config/modules.php`
|
||||
- `public/index.php`
|
||||
- `templates/`
|
||||
- `assets/`
|
||||
- `src/Post/`
|
||||
- `src/Site/`
|
||||
|
||||
Le coeur technique et les modules partagés (`Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy`, `Media`) proviennent du package Composer `netig/netslim-core` installé sous `vendor/`.
|
||||
|
||||
En pratique :
|
||||
- ce dépôt reste propriétaire du domaine `Post` et des intégrations applicatives du blog ;
|
||||
- la page `/admin` sert de tableau de bord au back-office ;
|
||||
- `/admin/settings`, `/admin/audit-log` et `/admin/notifications` servent de démonstration vivante des modules transverses du core.
|
||||
46
docs/ARCHITECTURE.md
Normal file
46
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Architecture de netslim-blog
|
||||
|
||||
`netslim-blog` est une application qui consomme `netslim-core` et ajoute deux modules locaux :
|
||||
- `Post` pour le domaine éditorial ;
|
||||
- `Site` pour les intégrations applicatives propres au blog.
|
||||
|
||||
## Dépendances vers le core
|
||||
|
||||
Le projet s’appuie sur :
|
||||
- `Netig\Netslim\Kernel\...`
|
||||
- `Netig\Netslim\Identity\...`
|
||||
- `Netig\Netslim\Settings\...`
|
||||
- `Netig\Netslim\AuditLog\...`
|
||||
- `Netig\Netslim\Notifications\...`
|
||||
- `Netig\Netslim\Taxonomy\...`
|
||||
- `Netig\Netslim\Media\...`
|
||||
|
||||
`ADMIN_HOME_PATH` pointe vers `/admin`, qui sert de tableau de bord au back-office du blog.
|
||||
|
||||
## Frontière entre le dépôt et le core
|
||||
|
||||
Dans ce dépôt, le code applicatif local est essentiellement :
|
||||
- `src/Post/`
|
||||
- `src/Site/`
|
||||
- `templates/`
|
||||
- `assets/`
|
||||
- `config/`
|
||||
- `public/`
|
||||
|
||||
Le code transverse et les modules partagés vivent dans `vendor/netig/netslim-core/` après installation Composer.
|
||||
Les répertoires runtime persistants (`var/`, `database/`, `public/media/`) restent toutefois ceux du projet blog lui-même.
|
||||
|
||||
## Démonstration des capacités du core
|
||||
|
||||
Le module `Site` démontre concrètement :
|
||||
- `Settings` via les réglages de titre, baseline, meta description et pagination ;
|
||||
- `Authorization` via les pages d’administration réservées aux permissions d’admin ;
|
||||
- `AuditLog` via la traçabilité des actions sur les réglages, notifications et articles ;
|
||||
- `Notifications` via une page d’envoi manuel et l’historique des dispatches.
|
||||
|
||||
Le module `Post` reste propriétaire :
|
||||
- des routes publiques et d’administration des contenus ;
|
||||
- des migrations `posts`, `post_media`, `posts_fts` ;
|
||||
- des usages concrets de `Taxonomy` et `Media`.
|
||||
|
||||
En pratique, si une évolution relève de `Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy` ou `Media`, elle doit être développée dans le dépôt `netslim-core`, puis intégrée ici via Composer.
|
||||
42
docs/DEPLOYMENT.md
Normal file
42
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Déploiement
|
||||
|
||||
Ce document regroupe les informations utiles pour déployer l'application avec Docker en production.
|
||||
|
||||
## Déploiement Docker en production
|
||||
|
||||
### Commandes minimales
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Définir APP_ENV=production, APP_URL, ADMIN_PASSWORD (12 caractères minimum) et la configuration SMTP
|
||||
docker compose up -d --build
|
||||
docker compose run --rm provision
|
||||
```
|
||||
|
||||
Le provisionnement reste explicite : le runtime HTTP ne crée pas le schéma automatiquement. Il initialise aussi les réglages par défaut du blog via le module local `Site`.
|
||||
|
||||
## Données persistées
|
||||
|
||||
La stack Docker initialise `./data/` sur l'hôte :
|
||||
|
||||
```text
|
||||
data/
|
||||
├── public/ # assets compilés et médias persistés
|
||||
├── database/ # base SQLite, lock de provisionnement et données runtime
|
||||
└── var/ # cache Twig/HTMLPurifier et logs
|
||||
```
|
||||
|
||||
## Reverse proxy et Caddy
|
||||
|
||||
Le Nginx de la stack Docker écoute sur `127.0.0.1:8888`. Pour exposer l'application publiquement, placez-le derrière un reverse proxy.
|
||||
|
||||
Exemple Caddy :
|
||||
|
||||
```caddy
|
||||
https://app.exemple.com {
|
||||
header Strict-Transport-Security max-age=31536000;
|
||||
reverse_proxy localhost:8888
|
||||
}
|
||||
```
|
||||
|
||||
Pensez aussi à renseigner `APP_URL` avec l'URL publique, `ADMIN_HOME_PATH=/admin`, la configuration `MAIL_*` utilisée par la démonstration des notifications, et à adapter `TRUSTED_PROXIES` si l'application est servie derrière un autre proxy que le Nginx Docker fourni.
|
||||
79
docs/DEVELOPMENT.md
Normal file
79
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Développement
|
||||
|
||||
Ce document sert de guide quotidien : lancer le projet, savoir où placer une modification et vérifier le résultat avant push.
|
||||
|
||||
Pour une première découverte du dépôt, commence plutôt par [ONBOARDING.md](ONBOARDING.md).
|
||||
|
||||
## Démarrage local
|
||||
|
||||
Prérequis : PHP 8.4+, Composer, Node.js 18+, `pdo`, `pdo_sqlite`, `fileinfo`, `gd`, `json`, `mbstring`, `session`, `dom`, `simplexml`.
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer frontend:install
|
||||
composer frontend:build
|
||||
cp .env.example .env
|
||||
composer provision
|
||||
composer start
|
||||
```
|
||||
|
||||
Pour recompiler le SCSS en continu :
|
||||
|
||||
```bash
|
||||
npm run watch
|
||||
```
|
||||
|
||||
## Où placer une modification ?
|
||||
|
||||
### Backend local au projet
|
||||
|
||||
- `src/Post/` : domaine éditorial du blog
|
||||
- `src/Site/` : intégrations applicatives du blog (dashboard, réglages, audit, notifications de démonstration)
|
||||
|
||||
### Code transverse provenant du core
|
||||
|
||||
`Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy` et `Media` proviennent de `netslim-core` et ne vivent pas dans `src/` de ce dépôt.
|
||||
|
||||
Règle pratique :
|
||||
- si la modification concerne seulement `Post`, `Site` ou les assets/templates du blog, elle a sa place ici ;
|
||||
- si elle concerne les modules du core, elle doit être portée dans `netslim-core`, puis intégrée via Composer.
|
||||
|
||||
### Frontend
|
||||
|
||||
- templates de domaine : `src/<Domaine>/UI/Templates/`
|
||||
- partials applicatifs transverses : `templates/Kernel/partials/`
|
||||
- scripts d’écran : `assets/js/`
|
||||
|
||||
## Points d’attention fonctionnels
|
||||
|
||||
Le blog démontre les modules transverses du core :
|
||||
- `Settings` pilote le titre, la baseline, la meta description, l’introduction et la pagination ;
|
||||
- `AuditLog` trace les modifications de réglages, les notifications envoyées et les opérations CRUD sur les articles ;
|
||||
- `Notifications` envoie un email de démonstration depuis `/admin/notifications` ;
|
||||
- `Authorization` réserve les pages d’administration transverses aux administrateurs.
|
||||
|
||||
## Vérifications avant push
|
||||
|
||||
```bash
|
||||
composer test
|
||||
composer stan
|
||||
composer cs:check
|
||||
```
|
||||
|
||||
Et si le frontend a changé :
|
||||
|
||||
```bash
|
||||
composer frontend:build
|
||||
```
|
||||
|
||||
Raccourci utile pour les vérifications backend :
|
||||
|
||||
```bash
|
||||
composer qa
|
||||
```
|
||||
|
||||
Pour la couverture :
|
||||
|
||||
```bash
|
||||
composer test:coverage
|
||||
```
|
||||
139
docs/FRONTEND.md
Normal file
139
docs/FRONTEND.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Frontend
|
||||
|
||||
Ce document sert de référence pour Twig, SCSS et JavaScript côté navigateur. Pour découvrir le projet, commencer plutôt par [ONBOARDING.md](ONBOARDING.md). Pour le travail quotidien, voir aussi [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le frontend repose sur :
|
||||
|
||||
- Twig pour le rendu HTML ;
|
||||
- Sass pour les styles ;
|
||||
- un peu de JavaScript côté navigateur ;
|
||||
- Trumbowyg pour l'édition riche côté admin.
|
||||
|
||||
L'objectif reste simple : pas de couche frontend applicative lourde, mais des scripts dédiés dès qu'un écran dépasse le petit snippet inline.
|
||||
|
||||
## Où trouver quoi ?
|
||||
|
||||
- templates de page : `src/<Domaine>/UI/Templates/`
|
||||
- partials de domaine : `src/<Domaine>/UI/Templates/partials/`
|
||||
- partials partagés : `templates/Kernel/partials/` (avec un fallback interne dans `vendor/netig/netslim-core/src/Kernel/Http/UI/Templates/partials/`)
|
||||
- SCSS global : `assets/scss/`
|
||||
- scripts frontend dédiés (sources) : `assets/js/`
|
||||
- scripts générés servis par l'application : `public/assets/js/`
|
||||
|
||||
## Lire un template Twig
|
||||
|
||||
Les constructions les plus courantes sont :
|
||||
|
||||
- `{% extends %}` : hériter d'un layout ;
|
||||
- `{% block %}` : remplir une zone du layout ;
|
||||
- `{% include %}` : réutiliser un partial ;
|
||||
- `{{ ... }}` : afficher une valeur ;
|
||||
- `{% if %}` / `{% for %}` : logique de présentation simple.
|
||||
|
||||
Principe : garder la logique métier hors de Twig. Un template décide **quoi afficher**, pas **comment fonctionne le métier**.
|
||||
|
||||
## Organisation SCSS
|
||||
|
||||
Le dossier `assets/scss/` est organisé en couches :
|
||||
|
||||
- `core/` : tokens, variables, mixins
|
||||
- `base/` : styles HTML globaux
|
||||
- `components/` : blocs BEM réutilisables
|
||||
- `layout/` : charpente globale de la page
|
||||
- `modules/` : styles propres à un domaine ou à un écran
|
||||
- `utilities/` : helpers ponctuels de layout (`u-*`)
|
||||
|
||||
Ordre d'import : `core -> base -> components -> layout -> modules -> utilities`.
|
||||
|
||||
## Règles de placement
|
||||
|
||||
- bloc réutilisable sur plusieurs vues : `components/`
|
||||
- structure globale de la page : `layout/`
|
||||
- style propre à un écran ou à un domaine : `modules/`
|
||||
- helper ponctuel de mise en page : `utilities/`
|
||||
|
||||
En cas d'hésitation entre `components/` et `modules/`, commencer dans `modules/` puis extraire à la deuxième vraie réutilisation.
|
||||
|
||||
## Convention BEM
|
||||
|
||||
- bloc : `.card`
|
||||
- élément : `.card__title`
|
||||
- modificateur : `.card--featured`
|
||||
|
||||
Règles :
|
||||
|
||||
- un fichier SCSS reste centré sur un ou plusieurs blocs cohérents ;
|
||||
- éviter les sélecteurs descendants hors `base/` et zones de contenu riche ;
|
||||
- préférer un modificateur BEM à une nouvelle classe parallèle ;
|
||||
- les utilitaires commencent par `u-`.
|
||||
|
||||
## Contenu riche et Trumbowyg
|
||||
|
||||
Le contenu HTML issu de l'éditeur est rendu dans `.rich-text` côté public. L'éditeur Trumbowyg est stylé pour rester cohérent avec le reste du projet : même typographie, mêmes états de focus, mêmes espacements généraux.
|
||||
|
||||
### Workflow média actuel
|
||||
|
||||
Le workflow média de l'éditeur est désormais le suivant :
|
||||
|
||||
- l'éditeur ouvre une **modale de médiathèque** ;
|
||||
- la modale charge `/admin/media/picker` dans un contexte allégé ;
|
||||
- la sélection ou le téléversement d'une image insère un `<img>` avec `data-media-id` ;
|
||||
- le backend conserve ensuite ce `data-media-id` à la sanitisation pour synchroniser `post_media`.
|
||||
|
||||
Le point important est le suivant : l'insertion d'une image ne repose pas sur une simple URL copiée, mais sur un HTML structuré permettant de garder le lien métier avec la médiathèque.
|
||||
|
||||
## Quand créer un partial Twig ?
|
||||
|
||||
Créer un partial si :
|
||||
|
||||
- un fragment est répété dans plusieurs pages ;
|
||||
- le template principal devient difficile à lire ;
|
||||
- un composant partagé a besoin d'un markup stable (flashs, pagination, badges, empty states, actions admin).
|
||||
|
||||
## Quand sortir du JavaScript dans un fichier dédié ?
|
||||
|
||||
Le projet évite une couche JavaScript applicative dédiée tant que le besoin reste limité.
|
||||
|
||||
Règles :
|
||||
|
||||
- réserver le JavaScript aux comportements réellement nécessaires ;
|
||||
- garder les snippets inline pour les comportements très courts et strictement locaux ;
|
||||
- extraire vers `assets/js/` dès qu'un écran devient riche, qu'il faut gérer plusieurs interactions ou qu'un comportement doit rester lisible ;
|
||||
- ne charger une librairie tierce dans un template que lorsqu'un écran en a réellement besoin.
|
||||
|
||||
Exemples actuels sortis des templates :
|
||||
|
||||
- `assets/js/post-editor-media-picker.js` pour le picker média de l'éditeur ;
|
||||
- `assets/js/media-admin.js` pour les actions de la médiathèque (copie, insertion, téléversement AJAX).
|
||||
|
||||
Après modification de ces fichiers, exécuter `composer frontend:build` pour les recopier dans `public/assets/js/`.
|
||||
|
||||
## Checklist avant d'ajouter un style ou un comportement
|
||||
|
||||
Avant d'ajouter du frontend, vérifier :
|
||||
|
||||
1. est-ce un token ? → `core/`
|
||||
2. est-ce un style global HTML ? → `base/`
|
||||
3. est-ce un bloc réutilisable ? → `components/`
|
||||
4. est-ce structurel à la page ? → `layout/`
|
||||
5. est-ce propre à un domaine ? → `modules/`
|
||||
6. est-ce un ajustement ponctuel de layout ? → `utilities/`
|
||||
7. est-ce un comportement minuscule et local, ou faut-il déjà un fichier JS dédié ?
|
||||
|
||||
## Vérifications manuelles utiles
|
||||
|
||||
Après une modification du picker média, de l'éditeur ou de la médiathèque :
|
||||
|
||||
1. ouvrir l'édition d'un post ;
|
||||
2. ouvrir la modale **Médiathèque** ;
|
||||
3. sélectionner ou téléverser une image ;
|
||||
4. vérifier l'insertion dans l'éditeur ;
|
||||
5. enregistrer le post puis vérifier l'usage du média dans `/admin/media`.
|
||||
|
||||
Après une modification Twig ou SCSS plus générale :
|
||||
|
||||
1. vérifier le rendu desktop et mobile ;
|
||||
2. vérifier les états de focus et les actions principales ;
|
||||
3. relire les partials partagés qui utilisent le même composant.
|
||||
44
docs/MAINTENANCE.md
Normal file
44
docs/MAINTENANCE.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Maintenance du dépôt
|
||||
|
||||
Ce document rassemble les règles simples à garder pour faire évoluer le projet sans le dégrader.
|
||||
|
||||
## Ce qui doit rester stable
|
||||
|
||||
### Backend
|
||||
|
||||
- architecture modulaire par domaines ;
|
||||
- séparation `Application / Domain / Infrastructure / UI` ;
|
||||
- services applicatifs découplés du framework HTTP ;
|
||||
- tests d'architecture présents ;
|
||||
- relation explicite `Post ↔ Media` via `post_media` ;
|
||||
- démonstration vivante des modules `Settings`, `AuditLog` et `Notifications` via le module local `Site` ;
|
||||
- recherche full-text FTS5 protégée contre les entrées purement ponctuées.
|
||||
|
||||
### Frontend
|
||||
|
||||
- templates colocalisés par domaine ;
|
||||
- partials transverses dans `templates/Kernel/` ;
|
||||
- SCSS centralisé dans `assets/` et organisé par couches ;
|
||||
- scripts d'écran riches externalisés dans `assets/js/` puis copiés dans `public/assets/js/` au build ;
|
||||
- médiathèque admin et picker de l'éditeur alignés sur le même workflow métier (`data-media-id`).
|
||||
|
||||
Pour les détails de déploiement Docker, du provisionnement et d'un reverse proxy comme Caddy, voir [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
## Règles de conduite
|
||||
|
||||
1. respecter `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md` et `docs/FRONTEND.md` ;
|
||||
2. éviter d'ajouter du transverse local alors que l'évolution relève de `netslim-core` ;
|
||||
3. préférer une amélioration locale à une nouvelle couche générique prématurée ;
|
||||
4. garder les tests et la documentation à jour quand une frontière change ;
|
||||
5. valider les parcours critiques manuellement avant une livraison importante ;
|
||||
6. préserver le découplage actuel entre `Post` et `Media` : `Post` garde ses VOs internes, l'adaptateur seul parle le contrat du domaine `Media` ;
|
||||
7. garder `Site` comme module d'intégration applicative du blog, sans y déplacer de responsabilités qui appartiennent au core.
|
||||
|
||||
## Dépôt prêt à évoluer quand
|
||||
|
||||
- PHPUnit est vert ;
|
||||
- PHPStan est vert ;
|
||||
- une vérification manuelle des parcours critiques a été effectuée si nécessaire ;
|
||||
- les assets frontend ont été reconstruits si besoin ;
|
||||
- aucun fichier temporaire n'est présent ;
|
||||
- la documentation reflète l'état réel du projet.
|
||||
78
docs/ONBOARDING.md
Normal file
78
docs/ONBOARDING.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Onboarding
|
||||
|
||||
Ce document sert de rampe d'accès au projet. Il n'essaie pas de tout expliquer : il te donne le bon ordre de lecture, les fichiers à ouvrir en premier et une première modification simple à réaliser sans te perdre.
|
||||
|
||||
## L'idée du projet en une minute
|
||||
|
||||
`netslim-blog` est une application blog concrète construite sur `netslim-core`.
|
||||
|
||||
Les idées à retenir sont simples :
|
||||
|
||||
- le code local est organisé par **domaines fonctionnels**, avec ici surtout `Post` et `Site` ;
|
||||
- les modules partagés (`Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy`, `Media`) viennent de `netslim-core` ;
|
||||
- chaque domaine suit la même structure : `Application`, `Domain`, `Infrastructure`, `UI` ;
|
||||
- l'application sert de cas réel pour faire vivre le socle partagé dans un projet éditorial.
|
||||
|
||||
## Ce qu'il faut comprendre la première heure
|
||||
|
||||
Si tu dois vite te repérer, concentre-toi sur ces deux flux :
|
||||
|
||||
- la **médiathèque** (`Media`) avec son picker intégré à l'éditeur ;
|
||||
- la relation explicite `Post ↔ Media` via `data-media-id` et `post_media` ;
|
||||
- le tableau de bord `/admin` et les pages `/admin/settings`, `/admin/audit-log`, `/admin/notifications`, qui montrent comment le blog intègre les modules transverses du core.
|
||||
|
||||
Ils donnent une bonne vision de l'état actuel du projet : modularité, ports inter-domaines, UI admin et coordination frontend/backend.
|
||||
|
||||
## Ordre de lecture conseillé
|
||||
|
||||
1. [../README.md](../README.md) pour la vue d'ensemble et le démarrage rapide
|
||||
2. [ARCHITECTURE.md](ARCHITECTURE.md) pour comprendre la structure du dépôt
|
||||
3. `public/index.php`, `bootstrap.php`, puis `vendor/netig/netslim-core/src/Kernel/Runtime/Bootstrap.php`, `vendor/netig/netslim-core/src/Kernel/Runtime/RuntimePaths.php`, `vendor/netig/netslim-core/src/Kernel/Runtime/DI/container.php`, `vendor/netig/netslim-core/src/Kernel/Runtime/Http/`, `vendor/netig/netslim-core/src/Kernel/Runtime/Routing/Routes.php`
|
||||
4. `src/Site/` puis `src/Post/` pour suivre les modules locaux
|
||||
5. `vendor/netig/netslim-core/src/Settings/`, `AuditLog/`, `Notifications/` et `Media/` pour comprendre les briques transverses démontrées par le blog
|
||||
6. [FRONTEND.md](FRONTEND.md) si tu touches à Twig, SCSS ou à l'éditeur
|
||||
7. [DEVELOPMENT.md](DEVELOPMENT.md) pour le workflow quotidien
|
||||
|
||||
## Comment lire un domaine
|
||||
|
||||
Parcourir un domaine dans cet ordre :
|
||||
|
||||
1. `UI/Http/Routes.php`
|
||||
2. `UI/Http/*Controller.php`
|
||||
3. `Application/`
|
||||
4. `Domain/`
|
||||
5. `Infrastructure/`
|
||||
|
||||
Ce parcours part d'une route ou d'un écran réel, puis remonte vers les cas d'usage et les implémentations.
|
||||
|
||||
## Première modification conseillée
|
||||
|
||||
Choisir une évolution **petite mais verticale** dans un domaine existant. Par exemple :
|
||||
|
||||
- ajuster un message ou un champ dans `/admin/settings` ;
|
||||
- modifier un partial Twig partagé ;
|
||||
- faire une petite évolution dans `Post` ou `Site` ;
|
||||
- ajuster un style ou un comportement lié à la médiathèque.
|
||||
|
||||
Éviter comme première tâche :
|
||||
|
||||
- un nouveau domaine complet ;
|
||||
- une modification transverse dans `netslim-core` sans avoir d'abord compris son impact ;
|
||||
- un changement d'architecture ;
|
||||
- une abstraction générique "pour préparer plus tard".
|
||||
|
||||
## Ce qu'il faut garder en tête
|
||||
|
||||
- un service applicatif sert de façade ; les workflows sensibles vivent dans des use cases dédiés ;
|
||||
- un contrôleur traduit HTTP vers l'application ;
|
||||
- `Infrastructure/` contient les implémentations concrètes ;
|
||||
- le code transverse vient du package `netslim-core` ;
|
||||
- le frontend reste simple, mais les écrans riches ont leurs scripts dédiés dans `assets/js/`, puis copiés dans `public/assets/js/` au build.
|
||||
|
||||
## Après la première lecture
|
||||
|
||||
Quand tu te sens à l'aise :
|
||||
|
||||
- passe à [DEVELOPMENT.md](DEVELOPMENT.md) pour le travail quotidien ;
|
||||
- garde [ARCHITECTURE.md](ARCHITECTURE.md) comme document de référence avant une évolution structurelle ;
|
||||
- ouvre [FRONTEND.md](FRONTEND.md) avant de toucher à l'éditeur, à la modale de médiathèque ou aux assets.
|
||||
37
docs/README.md
Normal file
37
docs/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Documentation
|
||||
|
||||
> Cette application nécessite une version récente de `netig/netslim-core` exposant les modules `Settings`, `AuditLog`, `Notifications` et l'authorization fine d'`Identity`. En cas d'erreur sur `SettingsModule` ou `SettingsReaderInterface`, mets d'abord à jour le dépôt du core puis relance `composer update netig/netslim-core`.
|
||||
|
||||
Ce dossier rassemble les documents utiles pour travailler sur `netslim-blog`.
|
||||
|
||||
## Par où commencer ?
|
||||
|
||||
- **Je découvre le projet** → [../README.md](../README.md)
|
||||
- **Je dois comprendre vite comment il est organisé** → [ONBOARDING.md](ONBOARDING.md)
|
||||
- **Je veux comprendre l'application livrée** → [APPLICATION.md](APPLICATION.md)
|
||||
- **Je veux comprendre le rôle du core et du code local** → [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- **Je travaille au quotidien sur le dépôt** → [DEVELOPMENT.md](DEVELOPMENT.md)
|
||||
- **Je touche à Twig, SCSS ou JavaScript d’écran** → [FRONTEND.md](FRONTEND.md)
|
||||
- **Je prépare un déploiement Docker** → [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||
- **Je veux connaître les garde-fous de long terme** → [MAINTENANCE.md](MAINTENANCE.md)
|
||||
- **Je contribue au dépôt** → [../CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
|
||||
## Si tu ne lis que trois documents
|
||||
|
||||
1. [../README.md](../README.md)
|
||||
2. [ONBOARDING.md](ONBOARDING.md)
|
||||
3. [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
||||
## Rôle de chaque document
|
||||
|
||||
| Document | Rôle |
|
||||
|---|---|
|
||||
| [../README.md](../README.md) | Vue d'ensemble, démarrage rapide, modules activés |
|
||||
| [ONBOARDING.md](ONBOARDING.md) | Parcours de découverte du dépôt |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Frontière entre le blog et `netslim-core` |
|
||||
| [APPLICATION.md](APPLICATION.md) | Vue d'ensemble de l'application blog livrée |
|
||||
| [DEVELOPMENT.md](DEVELOPMENT.md) | Guide de travail quotidien et checklist avant push |
|
||||
| [FRONTEND.md](FRONTEND.md) | Référence Twig, SCSS, JavaScript d'écran |
|
||||
| [DEPLOYMENT.md](DEPLOYMENT.md) | Déploiement Docker et répertoires persistants |
|
||||
| [MAINTENANCE.md](MAINTENANCE.md) | Garde-fous pour faire évoluer le dépôt sans le dégrader |
|
||||
| [../CONTRIBUTING.md](../CONTRIBUTING.md) | Règles de contribution |
|
||||
444
package-lock.json
generated
Normal file
444
package-lock.json
generated
Normal file
@@ -0,0 +1,444 @@
|
||||
{
|
||||
"name": "netslim-blog",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"jquery": "^4.0.0",
|
||||
"trumbowyg": "^2.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.98.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg=="
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.98.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
||||
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/trumbowyg": {
|
||||
"version": "2.31.0",
|
||||
"resolved": "https://registry.npmjs.org/trumbowyg/-/trumbowyg-2.31.0.tgz",
|
||||
"integrity": "sha512-I+DMiluTpLDx3yn6LR0TIVR7xIOjgtBQmpEE6Ofd+2yl5ruzY63q/yA/DfBuRVxdK7yDYSBe9FXpVjM1P2NdtA==",
|
||||
"peerDependencies": {
|
||||
"jquery": ">=1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"build:css": "sass assets/scss:public/assets/css --no-source-map --style=compressed",
|
||||
"build:vendor": "mkdir -p public/assets/vendor && cp node_modules/jquery/dist/jquery.min.js public/assets/vendor/ && cp -r node_modules/trumbowyg/dist public/assets/vendor/trumbowyg",
|
||||
"build": "npm run build:css && npm run build:vendor && npm run build:js",
|
||||
"watch": "sass --watch assets/scss:public/assets/css",
|
||||
"clean": "rm -rf public/assets",
|
||||
"build:js": "mkdir -p public/assets/js && cp assets/js/*.js public/assets/js/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.98.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^4.0.0",
|
||||
"trumbowyg": "^2.31.0"
|
||||
}
|
||||
}
|
||||
4
phpstan.neon
Normal file
4
phpstan.neon
Normal file
@@ -0,0 +1,4 @@
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- src
|
||||
25
phpunit.xml
Normal file
25
phpunit.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
displayDetailsOnPhpunitNotices="true"
|
||||
displayDetailsOnTestsThatTriggerErrors="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true">
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="netslim-blog">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
|
||||
</phpunit>
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 801 B |
31
public/index.php
Normal file
31
public/index.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use Netig\Netslim\Kernel\Http\Infrastructure\Request\RequestContext;
|
||||
|
||||
/** @var Netig\Netslim\Kernel\Runtime\Bootstrap $bootstrap */
|
||||
$bootstrap = require dirname(__DIR__) . '/bootstrap.php';
|
||||
$bootstrap->initializeInfrastructure();
|
||||
|
||||
$trustedProxies = RequestContext::trustedProxiesFromEnvironment($_ENV, $_SERVER);
|
||||
$sessionName = trim((string) ($_ENV['SESSION_NAME'] ?? 'netslim_session')) ?: 'netslim_session';
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
session_name($sessionName);
|
||||
|
||||
session_start([
|
||||
'cookie_secure' => RequestContext::isHttps($_SERVER, $trustedProxies),
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'cookie_lifetime' => 0,
|
||||
'use_strict_mode' => 1,
|
||||
]);
|
||||
|
||||
$app = $bootstrap->createHttpApp();
|
||||
$app->run();
|
||||
18
src/Post/Application/Command/CreatePostCommand.php
Normal file
18
src/Post/Application/Command/CreatePostCommand.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application\Command;
|
||||
|
||||
/**
|
||||
* Commande applicative décrivant la création d'un article.
|
||||
*/
|
||||
final readonly class CreatePostCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $content,
|
||||
public int $authorId,
|
||||
public ?int $categoryId = null,
|
||||
) {}
|
||||
}
|
||||
19
src/Post/Application/Command/UpdatePostCommand.php
Normal file
19
src/Post/Application/Command/UpdatePostCommand.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application\Command;
|
||||
|
||||
/**
|
||||
* Commande applicative décrivant la mise à jour d'un article.
|
||||
*/
|
||||
final readonly class UpdatePostCommand
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $title,
|
||||
public string $content,
|
||||
public string $newSlugInput = '',
|
||||
public ?int $categoryId = null,
|
||||
) {}
|
||||
}
|
||||
131
src/Post/Application/PostApplicationService.php
Normal file
131
src/Post/Application/PostApplicationService.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application;
|
||||
|
||||
use App\Post\Application\Command\CreatePostCommand;
|
||||
use App\Post\Application\Command\UpdatePostCommand;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
|
||||
final class PostApplicationService implements PostServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostRepositoryInterface $postRepository,
|
||||
private readonly CreatePost $createPost,
|
||||
private readonly UpdatePost $updatePost,
|
||||
private readonly DeletePost $deletePost,
|
||||
) {}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findAll($categoryId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function findPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countAll($categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findPage($perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findRecent(int $limit = 20): array
|
||||
{
|
||||
return $this->postRepository->findRecent($limit);
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findByUserId($userId, $categoryId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function findByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countByUserId($userId, $categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): Post
|
||||
{
|
||||
$post = $this->postRepository->findBySlug($slug);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $slug);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function findById(int $id): Post
|
||||
{
|
||||
$post = $this->postRepository->findById($id);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function create(string $title, string $content, int $authorId, ?int $categoryId = null): int
|
||||
{
|
||||
return $this->createPost->handle(new CreatePostCommand($title, $content, $authorId, $categoryId));
|
||||
}
|
||||
|
||||
public function update(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
|
||||
{
|
||||
$this->updatePost->handle(new UpdatePostCommand($id, $title, $content, $newSlugInput, $categoryId));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
return $this->postRepository->search($query, $categoryId, $authorId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function searchPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countSearch($query, $categoryId, $authorId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->deletePost->handle($id);
|
||||
}
|
||||
}
|
||||
97
src/Post/Application/PostServiceInterface.php
Normal file
97
src/Post/Application/PostServiceInterface.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use Netig\Netslim\Kernel\Pagination\Application\PaginatedResult;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
|
||||
/**
|
||||
* Contrat applicatif du domaine Post.
|
||||
*/
|
||||
interface PostServiceInterface
|
||||
{
|
||||
/**
|
||||
* Retourne l'ensemble des articles, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findAll(?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page d'articles, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
|
||||
|
||||
/**
|
||||
* Retourne les articles les plus récents.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findRecent(int $limit = 20): array;
|
||||
|
||||
/**
|
||||
* Retourne les articles d'un auteur, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page d'articles pour un auteur, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function findByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
|
||||
|
||||
/**
|
||||
* Retourne un article par slug.
|
||||
*
|
||||
* @throws NotFoundException Si aucun article ne correspond au slug fourni.
|
||||
*/
|
||||
public function findBySlug(string $slug): Post;
|
||||
|
||||
/**
|
||||
* Retourne un article par identifiant.
|
||||
*
|
||||
* @throws NotFoundException Si aucun article ne correspond à l'identifiant fourni.
|
||||
*/
|
||||
public function findById(int $id): Post;
|
||||
|
||||
/**
|
||||
* Recherche des articles selon une requête textuelle et des filtres optionnels.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page de résultats de recherche.
|
||||
*
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function searchPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult;
|
||||
|
||||
/**
|
||||
* Crée un article et retourne son identifiant persistant.
|
||||
*/
|
||||
public function create(string $title, string $content, int $authorId, ?int $categoryId = null): int;
|
||||
|
||||
/**
|
||||
* Met à jour un article existant.
|
||||
*
|
||||
* @throws NotFoundException Si l'article n'existe pas.
|
||||
*/
|
||||
public function update(int $id, string $title, string $content, string $slug = '', ?int $categoryId = null): void;
|
||||
|
||||
/**
|
||||
* Supprime un article existant.
|
||||
*
|
||||
* @throws NotFoundException Si l'article n'existe pas.
|
||||
*/
|
||||
public function delete(int $id): void;
|
||||
}
|
||||
55
src/Post/Application/UseCase/CreatePost.php
Normal file
55
src/Post/Application/UseCase/CreatePost.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application\UseCase;
|
||||
|
||||
use App\Post\Application\Command\CreatePostCommand;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
|
||||
/**
|
||||
* Cas d'usage de création d'un article avec sanitation HTML et génération de slug unique.
|
||||
*/
|
||||
final readonly class CreatePost
|
||||
{
|
||||
public function __construct(
|
||||
private PostRepositoryInterface $postRepository,
|
||||
private HtmlSanitizerInterface $htmlSanitizer,
|
||||
private PostSlugGenerator $slugGenerator,
|
||||
private TransactionManagerInterface $transactionManager,
|
||||
private PostMediaReferenceExtractorInterface $postMediaReferenceExtractor,
|
||||
private PostMediaUsageRepositoryInterface $postMediaUsageRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crée un article, nettoie son HTML et retourne son identifiant.
|
||||
*/
|
||||
public function handle(CreatePostCommand $command): int
|
||||
{
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($command->content);
|
||||
$post = new Post(0, $command->title, $sanitizedContent);
|
||||
$slug = $this->generateUniqueSlug($post->generateSlug());
|
||||
$mediaIds = $this->postMediaReferenceExtractor->extractMediaIds($sanitizedContent);
|
||||
|
||||
return $this->transactionManager->run(function () use ($post, $slug, $command, $mediaIds): int {
|
||||
$postId = $this->postRepository->create($post, $slug, $command->authorId, $command->categoryId);
|
||||
$this->postMediaUsageRepository->syncPostMedia($postId, $mediaIds);
|
||||
|
||||
return $postId;
|
||||
});
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $baseSlug): string
|
||||
{
|
||||
return $this->slugGenerator->unique(
|
||||
$baseSlug,
|
||||
fn (string $slug): bool => $this->postRepository->slugExists($slug),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/Post/Application/UseCase/DeletePost.php
Normal file
25
src/Post/Application/UseCase/DeletePost.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application\UseCase;
|
||||
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
|
||||
/**
|
||||
* Cas d'usage de suppression d'un article existant.
|
||||
*/
|
||||
final readonly class DeletePost
|
||||
{
|
||||
public function __construct(private PostRepositoryInterface $postRepository) {}
|
||||
|
||||
public function handle(int $id): void
|
||||
{
|
||||
$affected = $this->postRepository->delete($id);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Post/Application/UseCase/UpdatePost.php
Normal file
68
src/Post/Application/UseCase/UpdatePost.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application\UseCase;
|
||||
|
||||
use App\Post\Application\Command\UpdatePostCommand;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use Netig\Netslim\Kernel\Html\Application\HtmlSanitizerInterface;
|
||||
use Netig\Netslim\Kernel\Persistence\Application\TransactionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
|
||||
/**
|
||||
* Cas d'usage de mise à jour d'un article existant.
|
||||
*/
|
||||
final readonly class UpdatePost
|
||||
{
|
||||
public function __construct(
|
||||
private PostRepositoryInterface $postRepository,
|
||||
private HtmlSanitizerInterface $htmlSanitizer,
|
||||
private PostSlugGenerator $slugGenerator,
|
||||
private TransactionManagerInterface $transactionManager,
|
||||
private PostMediaReferenceExtractorInterface $postMediaReferenceExtractor,
|
||||
private PostMediaUsageRepositoryInterface $postMediaUsageRepository,
|
||||
) {}
|
||||
|
||||
public function handle(UpdatePostCommand $command): void
|
||||
{
|
||||
$current = $this->postRepository->findById($command->id);
|
||||
|
||||
if ($current === null) {
|
||||
throw new NotFoundException('Article', $command->id);
|
||||
}
|
||||
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($command->content);
|
||||
$post = new Post($command->id, $command->title, $sanitizedContent);
|
||||
$slugToUse = $current->getStoredSlug();
|
||||
$cleanSlugInput = $this->slugGenerator->normalize(trim($command->newSlugInput));
|
||||
|
||||
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
|
||||
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $command->id);
|
||||
}
|
||||
|
||||
$mediaIds = $this->postMediaReferenceExtractor->extractMediaIds($sanitizedContent);
|
||||
|
||||
$this->transactionManager->run(function () use ($command, $post, $slugToUse, $mediaIds): void {
|
||||
$affected = $this->postRepository->update($command->id, $post, $slugToUse, $command->categoryId);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $command->id);
|
||||
}
|
||||
|
||||
$this->postMediaUsageRepository->syncPostMedia($command->id, $mediaIds);
|
||||
});
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
|
||||
{
|
||||
return $this->slugGenerator->unique(
|
||||
$baseSlug,
|
||||
fn (string $slug): bool => $this->postRepository->slugExists($slug, $excludeId),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
src/Post/Domain/Entity/Post.php
Normal file
253
src/Post/Domain/Entity/Post.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\Entity;
|
||||
|
||||
use DateTime;
|
||||
use Netig\Netslim\Kernel\Support\Util\DateParser;
|
||||
use Netig\Netslim\Kernel\Support\Util\SlugHelper;
|
||||
|
||||
/**
|
||||
* Modèle représentant un article de blog.
|
||||
*
|
||||
* Encapsule les données et la validation d'un article.
|
||||
* Ce modèle est immuable après construction.
|
||||
* Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository)
|
||||
* pour éviter des requêtes supplémentaires à l'affichage.
|
||||
* La logique de présentation (excerpt, formatage) est déléguée à TwigPostExtension.
|
||||
*
|
||||
* Distinction slug :
|
||||
* - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter
|
||||
* un suffixe numérique pour lever les collisions, ex: "mon-article-2")
|
||||
* - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement
|
||||
* par PostApplicationService lors de la création/modification pour produire le slug à stocker
|
||||
*/
|
||||
final class Post
|
||||
{
|
||||
/**
|
||||
* @var DateTime Date de création — toujours non nulle après construction
|
||||
* (le constructeur accepte ?DateTime mais affecte `new DateTime()` si null)
|
||||
*/
|
||||
private readonly DateTime $createdAt;
|
||||
|
||||
/**
|
||||
* @var DateTime Date de dernière modification — toujours non nulle après construction
|
||||
*/
|
||||
private readonly DateTime $updatedAt;
|
||||
|
||||
/**
|
||||
* @param int $id Identifiant en base (0 pour un nouvel article)
|
||||
* @param string $title Titre de l'article (1–255 caractères)
|
||||
* @param string $content Contenu HTML de l'article (1–65 535 caractères)
|
||||
* @param string $slug Slug URL canonique, tel que stocké en base
|
||||
* @param int|null $authorId Identifiant de l'auteur (null si le compte a été supprimé)
|
||||
* @param string|null $authorUsername Nom de l'auteur dénormalisé (null si le compte a été supprimé)
|
||||
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
|
||||
* @param string|null $categoryName Nom de la catégorie dénormalisé (null si sans catégorie)
|
||||
* @param string|null $categorySlug Slug de la catégorie dénormalisé (null si sans catégorie)
|
||||
* @param DateTime|null $createdAt Date de création (défaut : maintenant)
|
||||
* @param DateTime|null $updatedAt Date de dernière modification (défaut : maintenant)
|
||||
*
|
||||
* @throws \InvalidArgumentException Si les données ne passent pas la validation
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $id,
|
||||
private readonly string $title,
|
||||
private readonly string $content,
|
||||
private readonly string $slug = '',
|
||||
private readonly ?int $authorId = null,
|
||||
private readonly ?string $authorUsername = null,
|
||||
private readonly ?int $categoryId = null,
|
||||
private readonly ?string $categoryName = null,
|
||||
private readonly ?string $categorySlug = null,
|
||||
?DateTime $createdAt = null,
|
||||
?DateTime $updatedAt = null,
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTime();
|
||||
$this->updatedAt = $updatedAt ?? new DateTime();
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une instance depuis un tableau associatif (ligne de base de données).
|
||||
*
|
||||
* @param array<string, mixed> $data Données issues de la base de données (avec JOIN users)
|
||||
*
|
||||
* @return self L'instance hydratée
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) ($data['id'] ?? 0),
|
||||
title: (string) ($data['title'] ?? ''),
|
||||
content: (string) ($data['content'] ?? ''),
|
||||
slug: (string) ($data['slug'] ?? ''),
|
||||
authorId: isset($data['author_id']) ? (int) $data['author_id'] : null,
|
||||
authorUsername: isset($data['author_username']) ? (string) $data['author_username'] : null,
|
||||
categoryId: isset($data['category_id']) ? (int) $data['category_id'] : null,
|
||||
categoryName: isset($data['category_name']) ? (string) $data['category_name'] : null,
|
||||
categorySlug: isset($data['category_slug']) ? (string) $data['category_slug'] : null,
|
||||
createdAt: DateParser::parse($data['created_at'] ?? null),
|
||||
updatedAt: DateParser::parse($data['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de l'article.
|
||||
*
|
||||
* @return int L'identifiant en base (0 si non encore persisté)
|
||||
*/
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le titre de l'article.
|
||||
*
|
||||
* @return string Le titre
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le contenu HTML de l'article.
|
||||
*
|
||||
* @return string Le contenu HTML sanitisé (purifié par HTMLPurifier à l'écriture)
|
||||
*/
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le slug canonique tel que stocké en base de données.
|
||||
*
|
||||
* Ce slug peut différer du résultat de generateSlug() si un suffixe numérique
|
||||
* a été ajouté lors de la création pour lever une collision
|
||||
* (ex: titre "Mon article" → slug en DB "mon-article-2").
|
||||
* C'est cette valeur qu'il faut utiliser pour construire les URLs publiques.
|
||||
*
|
||||
* @return string Le slug canonique (vide si l'article n'a pas encore été persisté)
|
||||
*/
|
||||
public function getStoredSlug(): string
|
||||
{
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de l'auteur.
|
||||
*
|
||||
* @return int|null L'identifiant de l'auteur, ou null si le compte a été supprimé
|
||||
*/
|
||||
public function getAuthorId(): ?int
|
||||
{
|
||||
return $this->authorId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom d'utilisateur de l'auteur.
|
||||
*
|
||||
* @return string|null Le nom d'utilisateur, ou null si le compte a été supprimé
|
||||
*/
|
||||
public function getAuthorUsername(): ?string
|
||||
{
|
||||
return $this->authorUsername;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de la catégorie de l'article.
|
||||
*
|
||||
* @return int|null L'identifiant de la catégorie, ou null si l'article est sans catégorie
|
||||
*/
|
||||
public function getCategoryId(): ?int
|
||||
{
|
||||
return $this->categoryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom de la catégorie de l'article.
|
||||
*
|
||||
* @return string|null Le nom de la catégorie, ou null si l'article est sans catégorie
|
||||
*/
|
||||
public function getCategoryName(): ?string
|
||||
{
|
||||
return $this->categoryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le slug de la catégorie de l'article.
|
||||
*
|
||||
* @return string|null Le slug de la catégorie, ou null si l'article est sans catégorie
|
||||
*/
|
||||
public function getCategorySlug(): ?string
|
||||
{
|
||||
return $this->categorySlug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la date de création de l'article.
|
||||
*
|
||||
* @return DateTime La date de création
|
||||
*/
|
||||
public function getCreatedAt(): DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la date de dernière modification de l'article.
|
||||
*
|
||||
* @return DateTime La date de dernière modification
|
||||
*/
|
||||
public function getUpdatedAt(): DateTime
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un slug URL-friendly calculé à partir du titre courant.
|
||||
*
|
||||
* Cette méthode est réservée à PostApplicationService pour produire le slug à stocker
|
||||
* lors de la création ou de la modification d'un article.
|
||||
* Pour construire une URL publique, utiliser getStoredSlug() qui retourne
|
||||
* le slug canonique tel qu'il est enregistré en base de données.
|
||||
*
|
||||
* La génération est déléguée à SlugHelper::generate() — voir sa documentation
|
||||
* pour le détail de l'algorithme (translittération ASCII, minuscules, tirets).
|
||||
*
|
||||
* @return string Le slug en minuscules avec tirets (ex: "ete-en-foret")
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
{
|
||||
return SlugHelper::generate($this->title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les données de l'article.
|
||||
*
|
||||
* @throws \InvalidArgumentException Si le titre est vide ou dépasse 255 caractères
|
||||
* @throws \InvalidArgumentException Si le contenu est vide ou dépasse 65 535 caractères
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
if ($this->title === '') {
|
||||
throw new \InvalidArgumentException('Le titre ne peut pas être vide');
|
||||
}
|
||||
|
||||
if (mb_strlen($this->title) > 255) {
|
||||
throw new \InvalidArgumentException('Le titre ne peut pas dépasser 255 caractères');
|
||||
}
|
||||
|
||||
if ($this->content === '') {
|
||||
throw new \InvalidArgumentException('Le contenu ne peut pas être vide');
|
||||
}
|
||||
|
||||
if (mb_strlen($this->content) > 65535) {
|
||||
throw new \InvalidArgumentException('Le contenu ne peut pas dépasser 65 535 caractères');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\Repository;
|
||||
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
|
||||
/**
|
||||
* Contrat de lecture et synchronisation des usages médias portés par les articles.
|
||||
*/
|
||||
interface PostMediaUsageRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Retourne le nombre total d'usages d'un média dans les articles.
|
||||
*/
|
||||
public function countUsages(int $mediaId): int;
|
||||
|
||||
/**
|
||||
* Retourne le nombre total d'usages pour chaque média demandé.
|
||||
*
|
||||
* @param list<int> $mediaIds Identifiants des médias à compter.
|
||||
* @return array<int, int> Table indexée par identifiant de média.
|
||||
*/
|
||||
public function countUsagesByMediaIds(array $mediaIds): array;
|
||||
|
||||
/**
|
||||
* Retourne un échantillon de références de contenus utilisant un média.
|
||||
*
|
||||
* @return list<PostMediaUsageReference>
|
||||
*/
|
||||
public function findUsages(int $mediaId, int $limit = 5): array;
|
||||
|
||||
/**
|
||||
* Retourne un échantillon de références de contenus pour chaque média demandé.
|
||||
*
|
||||
* @param list<int> $mediaIds Identifiants des médias à inspecter.
|
||||
* @return array<int, list<PostMediaUsageReference>> Table indexée par identifiant de média.
|
||||
*/
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array;
|
||||
|
||||
/**
|
||||
* Remplace les références médias associées à un post.
|
||||
*
|
||||
* @param list<int> $mediaIds Identifiants de médias extraits du contenu de l'article.
|
||||
*/
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void;
|
||||
}
|
||||
107
src/Post/Domain/Repository/PostRepositoryInterface.php
Normal file
107
src/Post/Domain/Repository/PostRepositoryInterface.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\Repository;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
|
||||
/**
|
||||
* Contrat de persistance des articles.
|
||||
*/
|
||||
interface PostRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Retourne tous les articles, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findAll(?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page d'articles, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne le nombre total d'articles, éventuellement filtrés par catégorie.
|
||||
*/
|
||||
public function countAll(?int $categoryId = null): int;
|
||||
|
||||
/**
|
||||
* Retourne les articles les plus récents.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findRecent(int $limit): array;
|
||||
|
||||
/**
|
||||
* Retourne les articles d'un auteur, éventuellement filtrés par catégorie.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page d'articles d'un auteur.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne le nombre total d'articles d'un auteur.
|
||||
*/
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int;
|
||||
|
||||
/**
|
||||
* Retourne un article par slug, ou `null` s'il n'existe pas.
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Post;
|
||||
|
||||
/**
|
||||
* Retourne un article par identifiant, ou `null` s'il n'existe pas.
|
||||
*/
|
||||
public function findById(int $id): ?Post;
|
||||
|
||||
/**
|
||||
* Persiste un nouvel article et retourne son identifiant.
|
||||
*/
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
|
||||
|
||||
/**
|
||||
* Met à jour un article et retourne le nombre de lignes affectées.
|
||||
*/
|
||||
public function update(int $id, Post $post, string $slug, ?int $categoryId): int;
|
||||
|
||||
/**
|
||||
* Supprime un article et retourne le nombre de lignes affectées.
|
||||
*/
|
||||
public function delete(int $id): int;
|
||||
|
||||
/**
|
||||
* Recherche des articles selon une requête textuelle et des filtres optionnels.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne une page de résultats de recherche.
|
||||
*
|
||||
* @return list<Post>
|
||||
*/
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array;
|
||||
|
||||
/**
|
||||
* Retourne le nombre total de résultats pour une recherche.
|
||||
*/
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int;
|
||||
|
||||
/**
|
||||
* Indique si un slug existe déjà, éventuellement hors d'un article donné.
|
||||
*/
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\Service;
|
||||
|
||||
/**
|
||||
* Extrait les identifiants de médias référencés dans le contenu HTML d'un article.
|
||||
*/
|
||||
interface PostMediaReferenceExtractorInterface
|
||||
{
|
||||
/**
|
||||
* Retourne les identifiants de médias présents dans le HTML fourni.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function extractMediaIds(string $html): array;
|
||||
}
|
||||
31
src/Post/Domain/Service/PostSlugGenerator.php
Normal file
31
src/Post/Domain/Service/PostSlugGenerator.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\Service;
|
||||
|
||||
use Netig\Netslim\Kernel\Support\Util\SlugHelper;
|
||||
|
||||
/**
|
||||
* Fournit des helpers de génération de slug pour les articles.
|
||||
*/
|
||||
final class PostSlugGenerator
|
||||
{
|
||||
public function normalize(string $input): string
|
||||
{
|
||||
return SlugHelper::generate($input);
|
||||
}
|
||||
|
||||
public function unique(string $baseSlug, callable $exists): string
|
||||
{
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while ($exists($slug)) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
++$counter;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
29
src/Post/Domain/ValueObject/PostMediaUsageReference.php
Normal file
29
src/Post/Domain/ValueObject/PostMediaUsageReference.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain\ValueObject;
|
||||
|
||||
final readonly class PostMediaUsageReference
|
||||
{
|
||||
public function __construct(
|
||||
private int $postId,
|
||||
private string $postTitle,
|
||||
private string $postEditPath,
|
||||
) {}
|
||||
|
||||
public function getPostId(): int
|
||||
{
|
||||
return $this->postId;
|
||||
}
|
||||
|
||||
public function getPostTitle(): string
|
||||
{
|
||||
return $this->postTitle;
|
||||
}
|
||||
|
||||
public function getPostEditPath(): string
|
||||
{
|
||||
return $this->postEditPath;
|
||||
}
|
||||
}
|
||||
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal file
74
src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
|
||||
final class HtmlPostMediaReferenceExtractor implements PostMediaReferenceExtractorInterface
|
||||
{
|
||||
/** @return list<int> */
|
||||
public function extractMediaIds(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$root = $this->loadFragment($document, $html);
|
||||
if ($root === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$nodes = $xpath->query('.//*[@data-media-id]', $root);
|
||||
if ($nodes === false || $nodes->length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mediaIds = [];
|
||||
foreach ($nodes as $node) {
|
||||
if (!$node instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mediaId = (int) trim($node->getAttribute('data-media-id'));
|
||||
if ($mediaId > 0) {
|
||||
$mediaIds[] = $mediaId;
|
||||
}
|
||||
}
|
||||
|
||||
$mediaIds = array_values(array_unique($mediaIds));
|
||||
sort($mediaIds);
|
||||
|
||||
return $mediaIds;
|
||||
}
|
||||
|
||||
private function loadFragment(\DOMDocument $document, string $html): ?\DOMElement
|
||||
{
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$wrapped = '<!DOCTYPE html><html><body><div data-post-media-root="1">' . $html . '</div></body></html>';
|
||||
$loaded = $document->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
|
||||
if ($loaded !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$xpath = new \DOMXPath($document);
|
||||
$nodes = $xpath->query('//div[@data-post-media-root="1"]');
|
||||
if ($nodes === false || $nodes->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$node = $nodes->item(0);
|
||||
|
||||
return $node instanceof \DOMElement ? $node : null;
|
||||
}
|
||||
}
|
||||
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal file
173
src/Post/Infrastructure/PdoPostMediaUsageRepository.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Implémentation PDO des lectures et synchronisations d'usages médias portés par les articles.
|
||||
*/
|
||||
final class PdoPostMediaUsageRepository implements PostMediaUsageRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly PDO $db) {}
|
||||
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM post_media WHERE media_id = :media_id');
|
||||
$stmt->execute([':media_id' => $mediaId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = $this->buildPlaceholders($mediaIds);
|
||||
$stmt = $this->db->prepare(
|
||||
sprintf(
|
||||
'SELECT media_id, COUNT(*) AS usage_count
|
||||
FROM post_media
|
||||
WHERE media_id IN (%s)
|
||||
GROUP BY media_id',
|
||||
$placeholders,
|
||||
),
|
||||
);
|
||||
$stmt->execute($mediaIds);
|
||||
|
||||
$countsByMediaId = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$countsByMediaId[(int) $row['media_id']] = (int) $row['usage_count'];
|
||||
}
|
||||
|
||||
return $countsByMediaId;
|
||||
}
|
||||
|
||||
/** @return list<PostMediaUsageReference> */
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'SELECT posts.id, posts.title
|
||||
FROM post_media
|
||||
INNER JOIN posts ON posts.id = post_media.post_id
|
||||
WHERE post_media.media_id = :media_id
|
||||
ORDER BY posts.id DESC
|
||||
LIMIT :limit',
|
||||
);
|
||||
$stmt->bindValue(':media_id', $mediaId, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): PostMediaUsageReference => new PostMediaUsageReference(
|
||||
(int) $row['id'],
|
||||
(string) $row['title'],
|
||||
'/admin/posts/edit/' . (int) $row['id'],
|
||||
),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, list<PostMediaUsageReference>>
|
||||
*/
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = max(1, $limit);
|
||||
$placeholders = $this->buildPlaceholders($mediaIds);
|
||||
$stmt = $this->db->prepare(
|
||||
sprintf(
|
||||
'SELECT post_media.media_id, posts.id, posts.title
|
||||
FROM post_media
|
||||
INNER JOIN posts ON posts.id = post_media.post_id
|
||||
WHERE post_media.media_id IN (%s)
|
||||
ORDER BY post_media.media_id ASC, posts.id DESC',
|
||||
$placeholders,
|
||||
),
|
||||
);
|
||||
$stmt->execute($mediaIds);
|
||||
|
||||
$referencesByMediaId = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$mediaId = (int) $row['media_id'];
|
||||
$referencesByMediaId[$mediaId] ??= [];
|
||||
|
||||
if (count($referencesByMediaId[$mediaId]) >= $limit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$referencesByMediaId[$mediaId][] = new PostMediaUsageReference(
|
||||
(int) $row['id'],
|
||||
(string) $row['title'],
|
||||
'/admin/posts/edit/' . (int) $row['id'],
|
||||
);
|
||||
}
|
||||
|
||||
return $referencesByMediaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
*/
|
||||
public function syncPostMedia(int $postId, array $mediaIds): void
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM post_media WHERE post_id = :post_id');
|
||||
$stmt->execute([':post_id' => $postId]);
|
||||
|
||||
$mediaIds = $this->normalizeMediaIds($mediaIds);
|
||||
if ($mediaIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $this->db->prepare(
|
||||
'INSERT OR IGNORE INTO post_media (post_id, media_id, usage_type)
|
||||
VALUES (:post_id, :media_id, :usage_type)',
|
||||
);
|
||||
|
||||
foreach ($mediaIds as $mediaId) {
|
||||
$insert->execute([
|
||||
':post_id' => $postId,
|
||||
':media_id' => $mediaId,
|
||||
':usage_type' => 'embedded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre et déduplique une liste d'identifiants de médias.
|
||||
*
|
||||
* @param list<int> $mediaIds
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeMediaIds(array $mediaIds): array
|
||||
{
|
||||
return array_values(array_unique(array_filter($mediaIds, static fn (int $mediaId): bool => $mediaId > 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une liste de placeholders positionnels pour une clause `IN`.
|
||||
*
|
||||
* @param list<int> $mediaIds
|
||||
*/
|
||||
private function buildPlaceholders(array $mediaIds): string
|
||||
{
|
||||
return implode(', ', array_fill(0, count($mediaIds), '?'));
|
||||
}
|
||||
}
|
||||
347
src/Post/Infrastructure/PdoPostRepository.php
Normal file
347
src/Post/Infrastructure/PdoPostRepository.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
class PdoPostRepository implements PostRepositoryInterface
|
||||
{
|
||||
private const SELECT = '
|
||||
SELECT posts.id, posts.title, posts.content, posts.slug,
|
||||
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
|
||||
users.username AS author_username,
|
||||
categories.name AS category_name,
|
||||
categories.slug AS category_slug
|
||||
FROM posts
|
||||
LEFT JOIN users ON users.id = posts.author_id
|
||||
LEFT JOIN categories ON categories.id = posts.category_id
|
||||
';
|
||||
|
||||
public function __construct(private readonly PDO $db) {}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT;
|
||||
$params = [];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' WHERE posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('Le comptage des posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
|
||||
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':author_id' => $authorId,
|
||||
':category_id' => $categoryId,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int) $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE posts
|
||||
SET title = :title, content = :content, slug = :slug,
|
||||
category_id = :category_id, updated_at = :updated_at
|
||||
WHERE id = :id',
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':category_id' => $categoryId,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
$normalizedQuery = $this->normalizeSearchQuery($query);
|
||||
|
||||
if ($normalizedQuery === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$params = [':q' => $normalizedQuery];
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($excludeId !== null && (int) $existingId === $excludeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function buildSearchSql(array &$params, ?int $categoryId = null, ?int $authorId = null): string
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND posts.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function normalizeSearchQuery(string $query): ?string
|
||||
{
|
||||
preg_match_all('/[\p{L}\p{N}_]+/u', $query, $matches);
|
||||
$terms = array_values(array_filter($matches[0], static fn (string $term): bool => $term !== ''));
|
||||
|
||||
if ($terms === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$quotedTerms = array_map(
|
||||
static fn (string $term): string => '"' . str_replace('"', '""', $term) . '"',
|
||||
$terms,
|
||||
);
|
||||
|
||||
return implode(' ', $quotedTerms);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function bindParams(PDOStatement $stmt, array $params): void
|
||||
{
|
||||
foreach ($params as $name => $value) {
|
||||
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[] */
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(static fn (array $row): Post => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal file
24
src/Post/Infrastructure/PdoTaxonUsageChecker.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Vérifie si un terme de taxonomie est encore référencé par au moins un post.
|
||||
*/
|
||||
final readonly class PdoTaxonUsageChecker implements TaxonUsageCheckerInterface
|
||||
{
|
||||
public function __construct(private PDO $db) {}
|
||||
|
||||
public function isTaxonInUse(int $taxonId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $taxonId]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
}
|
||||
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal file
73
src/Post/Infrastructure/PostMediaUsageReader.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\ValueObject\PostMediaUsageReference;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReference;
|
||||
|
||||
/**
|
||||
* Adaptateur entre les usages médias exposés par le module Post et le port lu par Media.
|
||||
*/
|
||||
final class PostMediaUsageReader implements MediaUsageReaderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostMediaUsageRepositoryInterface $postMediaUsageRepository,
|
||||
) {}
|
||||
|
||||
public function countUsages(int $mediaId): int
|
||||
{
|
||||
return $this->postMediaUsageRepository->countUsages($mediaId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function countUsagesByMediaIds(array $mediaIds): array
|
||||
{
|
||||
return $this->postMediaUsageRepository->countUsagesByMediaIds($mediaIds);
|
||||
}
|
||||
|
||||
/** @return list<MediaUsageReference> */
|
||||
public function findUsages(int $mediaId, int $limit = 5): array
|
||||
{
|
||||
return $this->mapUsageReferences($this->postMediaUsageRepository->findUsages($mediaId, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $mediaIds
|
||||
* @return array<int, list<MediaUsageReference>>
|
||||
*/
|
||||
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
|
||||
{
|
||||
$referencesByMediaId = [];
|
||||
|
||||
foreach ($this->postMediaUsageRepository->findUsagesByMediaIds($mediaIds, $limit) as $mediaId => $references) {
|
||||
$referencesByMediaId[$mediaId] = $this->mapUsageReferences($references);
|
||||
}
|
||||
|
||||
return $referencesByMediaId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme les références d'usage issues du module Post vers le VO exposé au module Media.
|
||||
*
|
||||
* @param list<PostMediaUsageReference> $references
|
||||
* @return list<MediaUsageReference>
|
||||
*/
|
||||
private function mapUsageReferences(array $references): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (PostMediaUsageReference $reference): MediaUsageReference => new MediaUsageReference(
|
||||
$reference->getPostId(),
|
||||
$reference->getPostTitle(),
|
||||
$reference->getPostEditPath(),
|
||||
),
|
||||
$references,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal file
26
src/Post/Infrastructure/PostSearchIndexer.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Synchronise l'index FTS5 du module Post avec les articles présents en base.
|
||||
*/
|
||||
final class PostSearchIndexer
|
||||
{
|
||||
public static function syncFtsIndex(PDO $db): void
|
||||
{
|
||||
$db->exec("
|
||||
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
COALESCE(strip_tags(p.content), ''),
|
||||
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
|
||||
FROM posts p
|
||||
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
|
||||
");
|
||||
}
|
||||
}
|
||||
45
src/Post/Infrastructure/dependencies.php
Normal file
45
src/Post/Infrastructure/dependencies.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\Application\PostServiceInterface;
|
||||
use App\Post\Application\UseCase\CreatePost;
|
||||
use App\Post\Application\UseCase\DeletePost;
|
||||
use App\Post\Application\UseCase\UpdatePost;
|
||||
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
|
||||
use App\Post\Domain\Repository\PostRepositoryInterface;
|
||||
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
|
||||
use App\Post\Domain\Service\PostSlugGenerator;
|
||||
use App\Post\Infrastructure\HtmlPostMediaReferenceExtractor;
|
||||
use App\Post\Infrastructure\PdoPostMediaUsageRepository;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use App\Post\Infrastructure\PdoTaxonUsageChecker;
|
||||
use App\Post\Infrastructure\PostMediaUsageReader;
|
||||
use App\Post\UI\Http\RssController;
|
||||
|
||||
use function DI\autowire;
|
||||
use function DI\factory;
|
||||
|
||||
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
|
||||
|
||||
return [
|
||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||
PostMediaUsageRepositoryInterface::class => autowire(PdoPostMediaUsageRepository::class),
|
||||
PostMediaReferenceExtractorInterface::class => autowire(HtmlPostMediaReferenceExtractor::class),
|
||||
TaxonUsageCheckerInterface::class => autowire(PdoTaxonUsageChecker::class),
|
||||
PostSlugGenerator::class => autowire(),
|
||||
CreatePost::class => autowire(),
|
||||
UpdatePost::class => autowire(),
|
||||
DeletePost::class => autowire(),
|
||||
MediaUsageReaderInterface::class => autowire(PostMediaUsageReader::class),
|
||||
RssController::class => factory(function (PostServiceInterface $postService): RssController {
|
||||
return new RssController(
|
||||
$postService,
|
||||
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
|
||||
$_ENV['APP_NAME'] ?? 'Netslim Blog',
|
||||
);
|
||||
}),
|
||||
];
|
||||
89
src/Post/Migrations/400_post_schema.php
Normal file
89
src/Post/Migrations/400_post_schema.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'up' => "
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL DEFAULT '',
|
||||
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
author_username,
|
||||
tokenize = 'unicode61 remove_diacritics 1'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_insert
|
||||
AFTER INSERT ON posts BEGIN
|
||||
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.title,
|
||||
COALESCE(strip_tags(NEW.content), ''),
|
||||
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_update
|
||||
AFTER UPDATE ON posts BEGIN
|
||||
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
||||
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.title,
|
||||
COALESCE(strip_tags(NEW.content), ''),
|
||||
COALESCE((SELECT username FROM users WHERE id = NEW.author_id), '')
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_delete
|
||||
AFTER DELETE ON posts BEGIN
|
||||
DELETE FROM posts_fts WHERE rowid = OLD.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
|
||||
AFTER UPDATE OF username ON users BEGIN
|
||||
DELETE FROM posts_fts
|
||||
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
|
||||
|
||||
INSERT INTO posts_fts(rowid, title, content, author_username)
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
COALESCE(strip_tags(p.content), ''),
|
||||
NEW.username
|
||||
FROM posts p
|
||||
WHERE p.author_id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_media (
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
|
||||
usage_type TEXT NOT NULL DEFAULT 'embedded',
|
||||
PRIMARY KEY (post_id, media_id, usage_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_media_post_id ON post_media(post_id);
|
||||
",
|
||||
'down' => "
|
||||
DROP TRIGGER IF EXISTS posts_fts_users_update;
|
||||
DROP TRIGGER IF EXISTS posts_fts_delete;
|
||||
DROP TRIGGER IF EXISTS posts_fts_update;
|
||||
DROP TRIGGER IF EXISTS posts_fts_insert;
|
||||
DROP TABLE IF EXISTS posts_fts;
|
||||
DROP INDEX IF EXISTS idx_post_media_post_id;
|
||||
DROP INDEX IF EXISTS idx_post_media_media_id;
|
||||
DROP TABLE IF EXISTS post_media;
|
||||
DROP INDEX IF EXISTS idx_posts_author_id;
|
||||
DROP TABLE IF EXISTS posts;
|
||||
",
|
||||
];
|
||||
53
src/Post/PostModule.php
Normal file
53
src/Post/PostModule.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use App\Post\Infrastructure\PostSearchIndexer;
|
||||
use App\Post\UI\Http\Routes;
|
||||
use App\Post\UI\Twig\TwigPostExtension;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ProvidesMigrationMaintenanceInterface;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ProvidesSchemaInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
final class PostModule implements ModuleInterface, ProvidesSchemaInterface, ProvidesMigrationMaintenanceInterface
|
||||
{
|
||||
public function definitions(): array
|
||||
{
|
||||
return require __DIR__ . '/Infrastructure/dependencies.php';
|
||||
}
|
||||
|
||||
/** @param App<ContainerInterface> $app */
|
||||
public function registerRoutes(App $app): void
|
||||
{
|
||||
Routes::register($app);
|
||||
}
|
||||
|
||||
public function templateNamespaces(): array
|
||||
{
|
||||
return ['Post' => __DIR__ . '/UI/Templates'];
|
||||
}
|
||||
|
||||
public function twigExtensions(): array
|
||||
{
|
||||
return [TwigPostExtension::class];
|
||||
}
|
||||
|
||||
public function migrationDirectories(): array
|
||||
{
|
||||
return [__DIR__ . '/Migrations'];
|
||||
}
|
||||
|
||||
public function requiredTables(): array
|
||||
{
|
||||
return ['posts', 'post_media', 'posts_fts'];
|
||||
}
|
||||
|
||||
public function afterMigrations(\PDO $db): void
|
||||
{
|
||||
PostSearchIndexer::syncFtsIndex($db);
|
||||
}
|
||||
}
|
||||
346
src/Post/UI/Http/PostController.php
Normal file
346
src/Post/UI/Http/PostController.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\UI\Http;
|
||||
|
||||
use App\Post\Application\PostServiceInterface;
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use App\Post\UI\Http\Request\PostFormRequest;
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Policy\Permission;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use Netig\Netslim\Kernel\Pagination\Infrastructure\PaginationPresenter;
|
||||
use Netig\Netslim\Kernel\Support\Exception\NotFoundException;
|
||||
use Netig\Netslim\Settings\Contracts\SettingsReaderInterface;
|
||||
use Netig\Netslim\Taxonomy\Contracts\TaxonomyReaderInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
/**
|
||||
* Contrôleur HTTP des pages publiques et administratives liées aux articles.
|
||||
*/
|
||||
final class PostController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly TaxonomyReaderInterface $taxonomyReader,
|
||||
private readonly SettingsReaderInterface $settings,
|
||||
private readonly AuthorizationServiceInterface $authorization,
|
||||
private readonly AuditLoggerInterface $auditLogger,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
private readonly SessionManagerInterface $sessionManager,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$taxonSlug = (string) ($params['categorie'] ?? '');
|
||||
$activeTaxon = null;
|
||||
$taxonId = null;
|
||||
$perPage = $this->publicPerPage();
|
||||
|
||||
if ($taxonSlug !== '') {
|
||||
$activeTaxon = $this->taxonomyReader->findBySlug($taxonSlug);
|
||||
$taxonId = $activeTaxon?->id;
|
||||
}
|
||||
|
||||
$paginated = $searchQuery !== ''
|
||||
? $this->postService->searchPaginated($searchQuery, $page, $perPage, $taxonId)
|
||||
: $this->postService->findPaginated($page, $perPage, $taxonId);
|
||||
|
||||
return $this->view->render($res, '@Post/home.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->taxonomyReader->findAll(),
|
||||
'activeCategory' => $activeTaxon,
|
||||
'searchQuery' => $searchQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche un article public à partir de son slug et retourne une 404 si le contenu est introuvable.
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function show(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->findBySlug((string) ($args['slug'] ?? ''));
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
return $this->view->render($res, '@Post/detail.twig', ['post' => $post]);
|
||||
}
|
||||
|
||||
public function admin(Request $req, Response $res): Response
|
||||
{
|
||||
$isPrivileged = $this->canManageAllContent();
|
||||
$userId = $this->sessionManager->getUserId();
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$taxonSlug = (string) ($params['categorie'] ?? '');
|
||||
$activeTaxon = null;
|
||||
$taxonId = null;
|
||||
$perPage = $this->adminPerPage();
|
||||
|
||||
if ($taxonSlug !== '') {
|
||||
$activeTaxon = $this->taxonomyReader->findBySlug($taxonSlug);
|
||||
$taxonId = $activeTaxon?->id;
|
||||
}
|
||||
|
||||
if ($searchQuery !== '') {
|
||||
$authorId = $isPrivileged ? null : (int) $userId;
|
||||
$paginated = $this->postService->searchPaginated(
|
||||
$searchQuery,
|
||||
$page,
|
||||
$perPage,
|
||||
$taxonId,
|
||||
$authorId,
|
||||
);
|
||||
} else {
|
||||
$paginated = $isPrivileged
|
||||
? $this->postService->findPaginated($page, $perPage, $taxonId)
|
||||
: $this->postService->findByUserIdPaginated((int) $userId, $page, $perPage, $taxonId);
|
||||
}
|
||||
|
||||
return $this->view->render($res, '@Post/admin/index.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->taxonomyReader->findAll(),
|
||||
'activeCategory' => $activeTaxon,
|
||||
'searchQuery' => $searchQuery,
|
||||
'error' => $this->flash->get('post_error'),
|
||||
'success' => $this->flash->get('post_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function form(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) ($args['id'] ?? 0);
|
||||
$post = null;
|
||||
|
||||
if ($id > 0) {
|
||||
try {
|
||||
$post = $this->postService->findById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->view->render($res, '@Post/admin/form.twig', [
|
||||
'post' => $post,
|
||||
'categories' => $this->taxonomyReader->findAll(),
|
||||
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
|
||||
'error' => $this->flash->get('post_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite la création d'un article depuis l'administration.
|
||||
*/
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
$postFormRequest = PostFormRequest::fromRequest($req);
|
||||
|
||||
try {
|
||||
$postId = $this->postService->create(
|
||||
$postFormRequest->title,
|
||||
$postFormRequest->content,
|
||||
$this->sessionManager->getUserId() ?? 0,
|
||||
$postFormRequest->categoryId,
|
||||
);
|
||||
$this->auditLogger->record(
|
||||
'post.created',
|
||||
'post',
|
||||
(string) $postId,
|
||||
$this->sessionManager->getUserId(),
|
||||
['title' => $postFormRequest->title],
|
||||
);
|
||||
$this->flash->set('post_success', 'L\'article a été créé avec succès');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
} catch (\Throwable $e) {
|
||||
$incidentId = $this->logUnexpectedError($req, $e, [
|
||||
'user_id' => $this->sessionManager->getUserId(),
|
||||
]);
|
||||
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function update(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) $args['id'];
|
||||
$postFormRequest = PostFormRequest::fromRequest($req);
|
||||
|
||||
try {
|
||||
$post = $this->postService->findById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->update(
|
||||
$id,
|
||||
$postFormRequest->title,
|
||||
$postFormRequest->content,
|
||||
$postFormRequest->slug,
|
||||
$postFormRequest->categoryId,
|
||||
);
|
||||
$this->auditLogger->record(
|
||||
'post.updated',
|
||||
'post',
|
||||
(string) $id,
|
||||
$this->sessionManager->getUserId(),
|
||||
['title' => $postFormRequest->title],
|
||||
);
|
||||
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
} catch (\Throwable $e) {
|
||||
$incidentId = $this->logUnexpectedError($req, $e, [
|
||||
'post_id' => $id,
|
||||
'user_id' => $this->sessionManager->getUserId(),
|
||||
]);
|
||||
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->findById((int) $args['id']);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->delete($post->getId());
|
||||
$this->auditLogger->record(
|
||||
'post.deleted',
|
||||
'post',
|
||||
(string) $post->getId(),
|
||||
$this->sessionManager->getUserId(),
|
||||
['title' => $post->getTitle()],
|
||||
);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
} catch (\Throwable $e) {
|
||||
$incidentId = $this->logUnexpectedError($req, $e, [
|
||||
'post_id' => $post->getId(),
|
||||
'user_id' => $this->sessionManager->getUserId(),
|
||||
]);
|
||||
$this->flash->set('post_error', "Une erreur inattendue s'est produite (réf. {$incidentId})");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
private function canEditPost(Post $post): bool
|
||||
{
|
||||
if ($this->canManageAllContent()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $post->getAuthorId() === $this->sessionManager->getUserId();
|
||||
}
|
||||
|
||||
private function canManageAllContent(): bool
|
||||
{
|
||||
return $this->authorization->canRole($this->currentRole(), Permission::CONTENT_MANAGE);
|
||||
}
|
||||
|
||||
private function currentRole(): string
|
||||
{
|
||||
if ($this->sessionManager->isAdmin()) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if ($this->sessionManager->isEditor()) {
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
return 'user';
|
||||
}
|
||||
|
||||
private function publicPerPage(): int
|
||||
{
|
||||
return max(1, min(24, $this->settings->getInt('blog.public_posts_per_page', 6)));
|
||||
}
|
||||
|
||||
private function adminPerPage(): int
|
||||
{
|
||||
return max(1, min(50, $this->settings->getInt('blog.admin_posts_per_page', 12)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function logUnexpectedError(Request $req, \Throwable $e, array $context = []): string
|
||||
{
|
||||
$incidentId = bin2hex(random_bytes(8));
|
||||
|
||||
$this->logger?->error('Post administration action failed', $context + [
|
||||
'incident_id' => $incidentId,
|
||||
'route' => (string) $req->getUri()->getPath(),
|
||||
'method' => $req->getMethod(),
|
||||
'exception_class' => $e::class,
|
||||
'exception_message' => $e->getMessage(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return $incidentId;
|
||||
}
|
||||
}
|
||||
33
src/Post/UI/Http/Request/PostFormRequest.php
Normal file
33
src/Post/UI/Http/Request/PostFormRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\UI\Http\Request;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Normalise les données issues du formulaire de création ou d'édition d'article.
|
||||
*/
|
||||
final readonly class PostFormRequest
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public string $content,
|
||||
public string $slug,
|
||||
public ?int $categoryId,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(ServerRequestInterface $request): self
|
||||
{
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = (array) $request->getParsedBody();
|
||||
|
||||
return new self(
|
||||
title: trim((string) ($data['title'] ?? '')),
|
||||
content: (string) ($data['content'] ?? ''),
|
||||
slug: trim((string) ($data['slug'] ?? '')),
|
||||
categoryId: isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/Post/UI/Http/Routes.php
Normal file
31
src/Post/UI/Http/Routes.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\UI\Http;
|
||||
|
||||
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
/**
|
||||
* Enregistre les routes HTTP du domaine Post.
|
||||
*/
|
||||
final class Routes
|
||||
{
|
||||
/** @param App<ContainerInterface> $app */
|
||||
public static function register(App $app): void
|
||||
{
|
||||
$app->get('/', [PostController::class, 'index']);
|
||||
$app->get('/article/{slug}', [PostController::class, 'show']);
|
||||
$app->get('/rss.xml', [RssController::class, 'feed']);
|
||||
|
||||
$app->group('/admin', function ($group): void {
|
||||
$group->get('/posts', [PostController::class, 'admin']);
|
||||
$group->get('/posts/edit/{id}', [PostController::class, 'form']);
|
||||
$group->post('/posts/create', [PostController::class, 'create']);
|
||||
$group->post('/posts/edit/{id}', [PostController::class, 'update']);
|
||||
$group->post('/posts/delete/{id}', [PostController::class, 'delete']);
|
||||
})->add(AuthMiddleware::class);
|
||||
}
|
||||
}
|
||||
60
src/Post/UI/Http/RssController.php
Normal file
60
src/Post/UI/Http/RssController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\UI\Http;
|
||||
|
||||
use App\Post\Application\PostServiceInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
/**
|
||||
* Produit le flux RSS public à partir des derniers articles publiés.
|
||||
*/
|
||||
class RssController
|
||||
{
|
||||
private const FEED_LIMIT = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly string $appUrl,
|
||||
private readonly string $appName,
|
||||
) {}
|
||||
|
||||
public function feed(Request $req, Response $res): Response
|
||||
{
|
||||
$posts = $this->postService->findRecent(self::FEED_LIMIT);
|
||||
$baseUrl = $this->appUrl;
|
||||
|
||||
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>');
|
||||
$channel = $xml->addChild('channel');
|
||||
$channel->addChild('title', htmlspecialchars($this->appName));
|
||||
$channel->addChild('link', $baseUrl . '/');
|
||||
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
|
||||
$channel->addChild('language', 'fr-FR');
|
||||
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$item = $channel->addChild('item');
|
||||
$item->addChild('title', htmlspecialchars($post->getTitle()));
|
||||
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
|
||||
$item->addChild('link', $postUrl);
|
||||
$item->addChild('guid', $postUrl);
|
||||
$excerpt = strip_tags($post->getContent());
|
||||
$excerpt = mb_strlen($excerpt) > 300 ? mb_substr($excerpt, 0, 300) . '…' : $excerpt;
|
||||
$item->addChild('description', htmlspecialchars($excerpt));
|
||||
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
|
||||
if ($post->getAuthorUsername() !== null) {
|
||||
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
|
||||
}
|
||||
if ($post->getCategoryName() !== null) {
|
||||
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
|
||||
}
|
||||
}
|
||||
|
||||
$body = $xml->asXML();
|
||||
$res->getBody()->write($body !== false ? $body : '');
|
||||
|
||||
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
}
|
||||
114
src/Post/UI/Templates/admin/form.twig
Normal file
114
src/Post/UI/Templates/admin/form.twig
Normal file
@@ -0,0 +1,114 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}
|
||||
{% if post is defined and post is not null and post.id > 0 %}Éditer l'article{% else %}Créer un article{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="/assets/vendor/trumbowyg/ui/trumbowyg.min.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
{% set pageTitle = post is defined and post is not null and post.id > 0 ? "Éditer l'article" : 'Créer un article' %}
|
||||
|
||||
{% include '@Kernel/partials/_admin_page_header.twig' with {
|
||||
title: pageTitle,
|
||||
secondary_action_href: '/admin/posts',
|
||||
secondary_action_label: 'Retour à la liste'
|
||||
} %}
|
||||
|
||||
<div class="form-container__panel">
|
||||
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
|
||||
|
||||
<form method="post" action="{{ action }}" class="form-container__form">
|
||||
{% include '@Kernel/partials/_csrf_fields.twig' %}
|
||||
|
||||
{% if post is defined and post is not null %}
|
||||
<p class="form-container__field">
|
||||
<label class="form-container__label">
|
||||
<span>Auteur</span>
|
||||
<input type="text" value="{{ post.authorUsername ?? 'inconnu' }}" disabled
|
||||
class="form-container__input form-container__input--disabled">
|
||||
</label>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="form-container__field">
|
||||
<label for="title" class="form-container__label">
|
||||
<span>Titre</span>
|
||||
<input type="text" id="title" name="title" value="{{ post.title|default('') }}" required maxlength="255"
|
||||
class="form-container__input">
|
||||
</label>
|
||||
</p>
|
||||
|
||||
{% if post is defined and post is not null and post.id > 0 %}
|
||||
<p class="form-container__field">
|
||||
<label for="slug" class="form-container__label">
|
||||
<span>Slug URL</span>
|
||||
<input type="text" id="slug" name="slug" value="{{ post.storedSlug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
maxlength="255" title="Lettres minuscules, chiffres et tirets uniquement"
|
||||
class="form-container__input">
|
||||
</label>
|
||||
<small class="form-container__hint">(URL actuelle : <a href="/article/{{ post.storedSlug }}" target="_blank">/article/{{ post.storedSlug }}</a>)</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="form-container__field">
|
||||
<label for="category_id" class="form-container__label">
|
||||
<span>Catégorie</span>
|
||||
<select id="category_id" name="category_id" class="form-container__select">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if post is not null and post.categoryId==category.id %}selected{% endif %}>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<p class="form-container__field form-container__field--editor">
|
||||
<label for="editor" class="form-container__label">
|
||||
<span>Contenu</span>
|
||||
</label>
|
||||
<textarea id="editor" name="content" required class="form-container__textarea">{{ post.content|default('') }}</textarea>
|
||||
</p>
|
||||
|
||||
<div id="media-picker-modal" class="media-picker-modal" aria-hidden="true" hidden>
|
||||
<div class="media-picker-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="media-picker-title">
|
||||
<div class="media-picker-modal__header">
|
||||
<h2 id="media-picker-title" class="media-picker-modal__title">Médiathèque</h2>
|
||||
<button id="media-picker-close" type="button" class="btn btn--secondary btn--sm">Fermer</button>
|
||||
</div>
|
||||
<div class="media-picker-modal__body">
|
||||
<iframe id="media-picker-frame" class="media-picker-modal__frame" title="Sélecteur de médias" loading="lazy" data-picker-src="/admin/media/picker"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include '@Kernel/partials/_admin_form_actions.twig' with {
|
||||
primary_label: post is defined and post is not null and post.id > 0 ? 'Mettre à jour' : 'Enregistrer',
|
||||
secondary_href: '/admin/posts',
|
||||
secondary_label: 'Annuler'
|
||||
} %}
|
||||
</form>
|
||||
|
||||
{% if post is defined and post is not null and post.id > 0 %}
|
||||
<div class="form-container__footer">
|
||||
<small>
|
||||
Créé le : {{ post.createdAt|date("d/m/Y à H:i") }}<br>
|
||||
Modifié le : {{ post.updatedAt|date("d/m/Y à H:i") }}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/assets/vendor/jquery.min.js"></script>
|
||||
<script src="/assets/vendor/trumbowyg/trumbowyg.min.js"></script>
|
||||
<script src="/assets/vendor/trumbowyg/langs/fr.min.js"></script>
|
||||
<script src="/assets/js/post-editor-media-picker.js"></script>
|
||||
{% endblock %}
|
||||
83
src/Post/UI/Templates/admin/index.twig
Normal file
83
src/Post/UI/Templates/admin/index.twig
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}Tableau de bord – Articles{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '@Kernel/partials/_admin_page_header.twig' with {
|
||||
title: 'Gestion des articles',
|
||||
primary_action_href: '/admin/posts/edit/0',
|
||||
primary_action_label: '+ Ajouter un article'
|
||||
} %}
|
||||
|
||||
{% include '@Post/partials/_search_form.twig' with {
|
||||
action: '/admin/posts',
|
||||
activeCategory: activeCategory,
|
||||
searchQuery: searchQuery,
|
||||
totalPosts: totalPosts,
|
||||
resetHref: '/admin/posts' ~ (activeCategory ? '?categorie=' ~ activeCategory.slug : '')
|
||||
} %}
|
||||
|
||||
{% include '@Post/partials/_category_filter.twig' with {
|
||||
categories: categories,
|
||||
activeCategory: activeCategory,
|
||||
allHref: '/admin/posts',
|
||||
itemHrefPrefix: '/admin/posts?categorie='
|
||||
} %}
|
||||
|
||||
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
|
||||
|
||||
{% if posts is not empty %}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titre</th>
|
||||
<th>Catégorie</th>
|
||||
<th>Auteur</th>
|
||||
<th>Créé le</th>
|
||||
<th>Modifié le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td data-label="Titre"><strong>{{ post.title }}</strong></td>
|
||||
<td data-label="Catégorie">
|
||||
{% if post.categoryName %}
|
||||
{% include '@Kernel/partials/_badge.twig' with {
|
||||
label: post.categoryName,
|
||||
modifier: 'category',
|
||||
href: '/admin/posts?categorie=' ~ post.categorySlug
|
||||
} %}
|
||||
{% else %}
|
||||
<span class="admin-table__muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Auteur">{{ post.authorUsername ?? 'inconnu' }}</td>
|
||||
<td data-label="Créé le">{{ post.createdAt|date("d/m/Y H:i") }}</td>
|
||||
<td data-label="Modifié le">{{ post.updatedAt|date("d/m/Y H:i") }}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="admin-actions">
|
||||
<a href="/admin/posts/edit/{{ post.id }}" class="btn btn--sm btn--secondary">Éditer</a>
|
||||
|
||||
{% include '@Kernel/partials/_admin_delete_form.twig' with {
|
||||
action: '/admin/posts/delete/' ~ post.id,
|
||||
confirm: 'Supprimer cet article ?'
|
||||
} %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% else %}
|
||||
{% include '@Kernel/partials/_empty_state.twig' with {
|
||||
title: 'Aucun article à afficher',
|
||||
message: searchQuery ? 'Aucun résultat pour « ' ~ searchQuery ~ ' ».' : 'Aucun article à gérer.',
|
||||
action_href: '/admin/posts/edit/0',
|
||||
action_label: 'Créer un article'
|
||||
} %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
51
src/Post/UI/Templates/detail.twig
Normal file
51
src/Post/UI/Templates/detail.twig
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}{{ post.title }} – {{ site.title }}{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
{% set excerpt = post_excerpt(post, 160) %}
|
||||
{% set thumb = post_thumbnail(post) %}
|
||||
<meta name="description" content="{{ excerpt }}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:title" content="{{ post.title }} – {{ site.title }}">
|
||||
<meta property="og:description" content="{{ excerpt }}">
|
||||
<meta property="og:url" content="{{ app_url }}{{ post_url(post) }}">
|
||||
{% if thumb %}
|
||||
<meta property="og:image" content="{{ app_url }}{{ thumb }}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="post">
|
||||
<h1 class="post__title">{{ post.title }}</h1>
|
||||
|
||||
<div class="post__meta">
|
||||
<small>
|
||||
Publié le {{ post.createdAt|date("d/m/Y à H:i") }}
|
||||
par <strong>{{ post.authorUsername ?? 'inconnu' }}</strong>
|
||||
</small>
|
||||
{% if post.categoryName %}
|
||||
{% include '@Kernel/partials/_badge.twig' with {
|
||||
label: post.categoryName,
|
||||
modifier: 'category',
|
||||
href: '/?categorie=' ~ post.categorySlug
|
||||
} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if post.updatedAt != post.createdAt %}
|
||||
<div class="post__updated">
|
||||
<small><em>Mis à jour le {{ post.updatedAt|date("d/m/Y à H:i") }}</em></small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="post__content rich-text">
|
||||
{{ post.content|raw }}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p class="post__back">
|
||||
<a href="/" class="post__back-link">← Retour aux articles</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
85
src/Post/UI/Templates/home.twig
Normal file
85
src/Post/UI/Templates/home.twig
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}{{ site.title }}{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<meta name="description" content="{% if activeCategory %}Contenus de la catégorie {{ activeCategory.name }} — {% endif %}{{ site.metaDescription }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ site.title }}">
|
||||
<meta property="og:description" content="{{ site.metaDescription }}">
|
||||
<meta property="og:url" content="{{ app_url }}/">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<h2>{{ site.title }}</h2>
|
||||
{% if site.homeIntro %}<p>{{ site.homeIntro }}</p>{% endif %}
|
||||
</section>
|
||||
|
||||
{% include '@Post/partials/_search_form.twig' with {
|
||||
action: '/',
|
||||
activeCategory: activeCategory,
|
||||
searchQuery: searchQuery,
|
||||
totalPosts: totalPosts,
|
||||
resetHref: '/' ~ (activeCategory ? '?categorie=' ~ activeCategory.slug : '')
|
||||
} %}
|
||||
|
||||
{% include '@Post/partials/_category_filter.twig' with {
|
||||
categories: categories,
|
||||
activeCategory: activeCategory,
|
||||
allHref: '/',
|
||||
itemHrefPrefix: '/?categorie='
|
||||
} %}
|
||||
|
||||
<div class="card-list card-list--contained">
|
||||
{% for post in posts %}
|
||||
{% set thumb = post_thumbnail(post) %}
|
||||
<article class="card">
|
||||
<a href="{{ post_url(post) }}" class="card__thumb-link" tabindex="-1" aria-hidden="true">
|
||||
{% if thumb %}
|
||||
<img class="card__thumb" src="{{ thumb }}" alt="">
|
||||
{% else %}
|
||||
<span class="card__initials" aria-hidden="true">{{ post_initials(post) }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="card__content">
|
||||
<div class="card__body">
|
||||
<h2 class="card__title">
|
||||
<a href="{{ post_url(post) }}" class="card__title-link">{{ post.title }}</a>
|
||||
</h2>
|
||||
|
||||
<div class="card__meta">
|
||||
<small>
|
||||
Publié le {{ post.createdAt|date("d/m/Y à H:i") }}
|
||||
par <strong>{{ post.authorUsername ?? 'inconnu' }}</strong>
|
||||
</small>
|
||||
{% if post.categoryName %}
|
||||
{% include '@Kernel/partials/_badge.twig' with {
|
||||
label: post.categoryName,
|
||||
modifier: 'category',
|
||||
href: '/?categorie=' ~ post.categorySlug
|
||||
} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="card__excerpt">{{ post_excerpt(post) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card__actions">
|
||||
<a href="{{ post_url(post) }}" class="card__actions-link">Lire la suite →</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
{% include '@Kernel/partials/_empty_state.twig' with {
|
||||
title: 'Aucun article trouvé',
|
||||
message: 'Aucun article publié' ~ (searchQuery ? ' pour « ' ~ searchQuery ~ ' »' : (activeCategory ? ' dans cette catégorie' : '')) ~ '.',
|
||||
action_href: '/',
|
||||
action_label: 'Réinitialiser les filtres'
|
||||
} %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %}
|
||||
{% endblock %}
|
||||
14
src/Post/UI/Templates/partials/_category_filter.twig
Normal file
14
src/Post/UI/Templates/partials/_category_filter.twig
Normal file
@@ -0,0 +1,14 @@
|
||||
{% if categories is not empty %}
|
||||
<nav class="category-filter" aria-label="Filtrer par catégorie">
|
||||
<a href="{{ allHref }}"
|
||||
class="category-filter__item{% if activeCategory is null %} category-filter__item--active{% endif %}">
|
||||
Tous
|
||||
</a>
|
||||
{% for category in categories %}
|
||||
<a href="{{ itemHrefPrefix }}{{ category.slug }}"
|
||||
class="category-filter__item{% if activeCategory and activeCategory.id == category.id %} category-filter__item--active{% endif %}">
|
||||
{{ category.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
23
src/Post/UI/Templates/partials/_search_form.twig
Normal file
23
src/Post/UI/Templates/partials/_search_form.twig
Normal file
@@ -0,0 +1,23 @@
|
||||
<form method="get" action="{{ action }}" class="search-bar">
|
||||
{% if activeCategory %}
|
||||
<input type="hidden" name="categorie" value="{{ activeCategory.slug }}">
|
||||
{% endif %}
|
||||
|
||||
<input type="search" name="q" value="{{ searchQuery }}"
|
||||
placeholder="Rechercher un article…" class="search-bar__input" aria-label="Recherche">
|
||||
<button type="submit" class="search-bar__btn">Rechercher</button>
|
||||
|
||||
{% if searchQuery %}
|
||||
<a href="{{ resetHref }}" class="search-bar__reset">✕</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if searchQuery %}
|
||||
<p class="search-bar__info">
|
||||
{% if totalPosts > 0 %}
|
||||
{{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} »
|
||||
{% else %}
|
||||
Aucun résultat pour « {{ searchQuery }} »
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
99
src/Post/UI/Twig/TwigPostExtension.php
Normal file
99
src/Post/UI/Twig/TwigPostExtension.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\UI\Twig;
|
||||
|
||||
use App\Post\Domain\Entity\Post;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class TwigPostExtension extends AbstractExtension
|
||||
{
|
||||
/** @return TwigFunction[] */
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('post_excerpt', fn (Post $post, int $length = 400) => self::excerpt($post, $length), ['is_safe' => ['html']]),
|
||||
new TwigFunction('post_url', fn (Post $post) => '/article/' . $post->getStoredSlug()),
|
||||
new TwigFunction('post_thumbnail', fn (Post $post) => self::thumbnail($post)),
|
||||
new TwigFunction('post_initials', fn (Post $post) => self::initials($post)),
|
||||
];
|
||||
}
|
||||
|
||||
private static function excerpt(Post $post, int $length): string
|
||||
{
|
||||
$allowed = '<ul><ol><li><strong><em><b><i>';
|
||||
$html = strip_tags($post->getContent(), $allowed);
|
||||
|
||||
if (mb_strlen(strip_tags($html)) <= $length) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$truncated = '';
|
||||
$count = 0;
|
||||
$inTag = false;
|
||||
|
||||
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
|
||||
$char = mb_substr($html, $i, 1);
|
||||
if ($char === '<') {
|
||||
$inTag = true;
|
||||
}
|
||||
$truncated .= $char;
|
||||
if ($inTag) {
|
||||
if ($char === '>') {
|
||||
$inTag = false;
|
||||
}
|
||||
} else {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
|
||||
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
|
||||
$closes = substr_count($truncated, "</{$tag}>");
|
||||
for ($j = $closes; $j < $opens; $j++) {
|
||||
$truncated .= "</{$tag}>";
|
||||
}
|
||||
}
|
||||
|
||||
return $truncated . '…';
|
||||
}
|
||||
|
||||
private static function thumbnail(Post $post): ?string
|
||||
{
|
||||
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function initials(Post $post): string
|
||||
{
|
||||
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
|
||||
$words = array_filter(
|
||||
preg_split('/\s+/', trim($post->getTitle())) ?: [],
|
||||
static function (string $w) use ($stopWords): bool {
|
||||
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_"));
|
||||
|
||||
return $normalized !== '' && mb_strlen($normalized) > 1 && !in_array($normalized, $stopWords, true);
|
||||
},
|
||||
);
|
||||
|
||||
if (empty($words)) {
|
||||
$first = mb_substr(trim($post->getTitle()), 0, 1);
|
||||
|
||||
return $first !== '' ? mb_strtoupper($first) : '?';
|
||||
}
|
||||
|
||||
$words = array_values($words);
|
||||
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
|
||||
|
||||
if (isset($words[1])) {
|
||||
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
|
||||
}
|
||||
|
||||
return $initials;
|
||||
}
|
||||
}
|
||||
56
src/Site/Infrastructure/DefaultSiteSettingsProvisioner.php
Normal file
56
src/Site/Infrastructure/DefaultSiteSettingsProvisioner.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Site\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Dépose les réglages initiaux du blog lors du provisionnement.
|
||||
*/
|
||||
final class DefaultSiteSettingsProvisioner
|
||||
{
|
||||
public static function provision(PDO $db): void
|
||||
{
|
||||
$defaults = [
|
||||
'site.title' => $_ENV['APP_NAME'] ?? 'Netslim Blog',
|
||||
'site.tagline' => 'Un blog éditorial construit sur netslim-core.',
|
||||
'site.meta_description' => 'Application blog construite sur netslim-core.',
|
||||
'blog.home_intro' => 'Bienvenue sur le blog. Cette application démontre l’utilisation de Settings, Authorization, AuditLog et Notifications au-dessus de netslim-core.',
|
||||
'blog.public_posts_per_page' => 6,
|
||||
'blog.admin_posts_per_page' => 12,
|
||||
'notifications.demo_recipient' => $_ENV['MAIL_FROM'] ?? '',
|
||||
];
|
||||
|
||||
$statement = $db->prepare(
|
||||
'INSERT OR IGNORE INTO settings (setting_key, setting_value, value_type, updated_at)
|
||||
VALUES (:key, :value, :type, CURRENT_TIMESTAMP)',
|
||||
);
|
||||
|
||||
foreach ($defaults as $key => $value) {
|
||||
[$storedValue, $type] = self::encode($value);
|
||||
$statement->execute([
|
||||
':key' => $key,
|
||||
':value' => $storedValue,
|
||||
':type' => $type,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string|null,1:string}
|
||||
*/
|
||||
private static function encode(mixed $value): array
|
||||
{
|
||||
return match (true) {
|
||||
$value === null => [null, 'null'],
|
||||
is_bool($value) => [$value ? '1' : '0', 'bool'],
|
||||
is_int($value) => [(string) $value, 'int'],
|
||||
is_float($value) => [(string) $value, 'float'],
|
||||
is_string($value) => [$value, 'string'],
|
||||
is_array($value) => [json_encode($value, JSON_THROW_ON_ERROR), 'json'],
|
||||
default => throw new \InvalidArgumentException('Type de réglage par défaut non supporté'),
|
||||
};
|
||||
}
|
||||
}
|
||||
13
src/Site/Infrastructure/dependencies.php
Normal file
13
src/Site/Infrastructure/dependencies.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Site\UI\Http\SiteController;
|
||||
use App\Site\UI\Twig\SiteSettingsExtension;
|
||||
|
||||
use function DI\autowire;
|
||||
|
||||
return [
|
||||
SiteController::class => autowire(),
|
||||
SiteSettingsExtension::class => autowire(),
|
||||
];
|
||||
50
src/Site/SiteModule.php
Normal file
50
src/Site/SiteModule.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Site;
|
||||
|
||||
use App\Site\Infrastructure\DefaultSiteSettingsProvisioner;
|
||||
use App\Site\UI\Http\Routes;
|
||||
use App\Site\UI\Twig\SiteSettingsExtension;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ModuleInterface;
|
||||
use Netig\Netslim\Kernel\Runtime\Module\ProvidesProvisioningInterface;
|
||||
use PDO;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
/**
|
||||
* Module applicatif du blog.
|
||||
*
|
||||
* Il regroupe les intégrations propres au projet : pages d'administration
|
||||
* transverses, réglages éditoriaux, démonstration des notifications et
|
||||
* exposition des réglages Twig utilisés par les layouts publics.
|
||||
*/
|
||||
final class SiteModule implements ModuleInterface, ProvidesProvisioningInterface
|
||||
{
|
||||
public function definitions(): array
|
||||
{
|
||||
return require __DIR__ . '/Infrastructure/dependencies.php';
|
||||
}
|
||||
|
||||
/** @param App<ContainerInterface> $app */
|
||||
public function registerRoutes(App $app): void
|
||||
{
|
||||
Routes::register($app);
|
||||
}
|
||||
|
||||
public function templateNamespaces(): array
|
||||
{
|
||||
return ['Site' => __DIR__ . '/UI/Templates'];
|
||||
}
|
||||
|
||||
public function twigExtensions(): array
|
||||
{
|
||||
return [SiteSettingsExtension::class];
|
||||
}
|
||||
|
||||
public function provision(PDO $db): void
|
||||
{
|
||||
DefaultSiteSettingsProvisioner::provision($db);
|
||||
}
|
||||
}
|
||||
28
src/Site/UI/Http/Routes.php
Normal file
28
src/Site/UI/Http/Routes.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Site\UI\Http;
|
||||
|
||||
use Netig\Netslim\Identity\UI\Http\Middleware\AuthMiddleware;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
/**
|
||||
* Enregistre les routes applicatives transverses du blog.
|
||||
*/
|
||||
final class Routes
|
||||
{
|
||||
/** @param App<ContainerInterface> $app */
|
||||
public static function register(App $app): void
|
||||
{
|
||||
$app->group('/admin', function ($group): void {
|
||||
$group->get('', [SiteController::class, 'dashboard']);
|
||||
$group->get('/settings', [SiteController::class, 'settings']);
|
||||
$group->post('/settings', [SiteController::class, 'saveSettings']);
|
||||
$group->get('/audit-log', [SiteController::class, 'auditLog']);
|
||||
$group->get('/notifications', [SiteController::class, 'notifications']);
|
||||
$group->post('/notifications/send', [SiteController::class, 'sendNotification']);
|
||||
})->add(AuthMiddleware::class);
|
||||
}
|
||||
}
|
||||
224
src/Site/UI/Http/SiteController.php
Normal file
224
src/Site/UI/Http/SiteController.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Site\UI\Http;
|
||||
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditLoggerInterface;
|
||||
use Netig\Netslim\AuditLog\Contracts\AuditLogReaderInterface;
|
||||
use Netig\Netslim\Identity\Application\AuthorizationServiceInterface;
|
||||
use Netig\Netslim\Identity\Domain\Policy\Permission;
|
||||
use Netig\Netslim\Kernel\Http\Application\Flash\FlashServiceInterface;
|
||||
use Netig\Netslim\Kernel\Http\Application\Session\SessionManagerInterface;
|
||||
use Netig\Netslim\Notifications\Application\NotificationServiceInterface;
|
||||
use Netig\Netslim\Settings\Application\SettingsServiceInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
/**
|
||||
* Contrôleur applicatif des pages d'administration transverses du blog.
|
||||
*/
|
||||
final class SiteController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly SettingsServiceInterface $settings,
|
||||
private readonly AuditLoggerInterface $auditLogger,
|
||||
private readonly AuditLogReaderInterface $auditLogReader,
|
||||
private readonly NotificationServiceInterface $notifications,
|
||||
private readonly AuthorizationServiceInterface $authorization,
|
||||
private readonly SessionManagerInterface $sessionManager,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function dashboard(Request $request, Response $response): Response
|
||||
{
|
||||
return $this->view->render($response, '@Site/admin/dashboard.twig', [
|
||||
'settingsPreview' => [
|
||||
'title' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
|
||||
'tagline' => $this->settings->getString('site.tagline', ''),
|
||||
],
|
||||
'recentAuditEntries' => $this->can(Permission::AUDIT_LOG_VIEW)
|
||||
? $this->auditLogReader->listRecent(5)
|
||||
: [],
|
||||
'recentNotifications' => $this->can(Permission::NOTIFICATIONS_SEND)
|
||||
? $this->notifications->recent(5)
|
||||
: [],
|
||||
'flashError' => $this->flash->get('site_error'),
|
||||
'flashSuccess' => $this->flash->get('site_success'),
|
||||
'permissions' => [
|
||||
'settings' => $this->can(Permission::SETTINGS_MANAGE),
|
||||
'auditLog' => $this->can(Permission::AUDIT_LOG_VIEW),
|
||||
'notifications' => $this->can(Permission::NOTIFICATIONS_SEND),
|
||||
'content' => $this->can(Permission::CONTENT_MANAGE),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(Request $request, Response $response): Response
|
||||
{
|
||||
if (!$this->can(Permission::SETTINGS_MANAGE)) {
|
||||
return $this->deny($response, 'Vous ne pouvez pas modifier les réglages du site.');
|
||||
}
|
||||
|
||||
return $this->view->render($response, '@Site/admin/settings.twig', [
|
||||
'values' => [
|
||||
'site_title' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
|
||||
'site_tagline' => $this->settings->getString('site.tagline', ''),
|
||||
'site_meta_description' => $this->settings->getString('site.meta_description', ''),
|
||||
'home_intro' => $this->settings->getString('blog.home_intro', ''),
|
||||
'public_posts_per_page' => $this->settings->getInt('blog.public_posts_per_page', 6),
|
||||
'admin_posts_per_page' => $this->settings->getInt('blog.admin_posts_per_page', 12),
|
||||
'demo_recipient' => $this->settings->getString('notifications.demo_recipient', ''),
|
||||
],
|
||||
'error' => $this->flash->get('site_error'),
|
||||
'success' => $this->flash->get('site_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveSettings(Request $request, Response $response): Response
|
||||
{
|
||||
if (!$this->can(Permission::SETTINGS_MANAGE)) {
|
||||
return $this->deny($response, 'Vous ne pouvez pas modifier les réglages du site.');
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$publicPerPage = max(1, min(24, (int) ($data['public_posts_per_page'] ?? 6)));
|
||||
$adminPerPage = max(1, min(50, (int) ($data['admin_posts_per_page'] ?? 12)));
|
||||
|
||||
$changes = [
|
||||
'site.title' => trim((string) ($data['site_title'] ?? '')),
|
||||
'site.tagline' => trim((string) ($data['site_tagline'] ?? '')),
|
||||
'site.meta_description' => trim((string) ($data['site_meta_description'] ?? '')),
|
||||
'blog.home_intro' => trim((string) ($data['home_intro'] ?? '')),
|
||||
'blog.public_posts_per_page' => $publicPerPage,
|
||||
'blog.admin_posts_per_page' => $adminPerPage,
|
||||
'notifications.demo_recipient' => trim((string) ($data['demo_recipient'] ?? '')),
|
||||
];
|
||||
|
||||
if ($changes['site.title'] === '') {
|
||||
$this->flash->set('site_error', 'Le titre du site ne peut pas être vide.');
|
||||
|
||||
return $response->withHeader('Location', '/admin/settings')->withStatus(302);
|
||||
}
|
||||
|
||||
foreach ($changes as $key => $value) {
|
||||
$this->settings->set($key, $value);
|
||||
}
|
||||
|
||||
$this->auditLogger->record(
|
||||
action: 'settings.updated',
|
||||
resourceType: 'settings',
|
||||
resourceId: 'blog',
|
||||
actorUserId: $this->sessionManager->getUserId(),
|
||||
context: ['keys' => array_keys($changes)],
|
||||
);
|
||||
|
||||
$this->flash->set('site_success', 'Les réglages du blog ont été enregistrés.');
|
||||
|
||||
return $response->withHeader('Location', '/admin/settings')->withStatus(302);
|
||||
}
|
||||
|
||||
public function auditLog(Request $request, Response $response): Response
|
||||
{
|
||||
if (!$this->can(Permission::AUDIT_LOG_VIEW)) {
|
||||
return $this->deny($response, 'Vous ne pouvez pas consulter le journal d\'audit.');
|
||||
}
|
||||
|
||||
return $this->view->render($response, '@Site/admin/audit-log.twig', [
|
||||
'entries' => $this->auditLogReader->listRecent(100),
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifications(Request $request, Response $response): Response
|
||||
{
|
||||
if (!$this->can(Permission::NOTIFICATIONS_SEND)) {
|
||||
return $this->deny($response, 'Vous ne pouvez pas envoyer de notifications.');
|
||||
}
|
||||
|
||||
return $this->view->render($response, '@Site/admin/notifications.twig', [
|
||||
'defaultRecipient' => $this->settings->getString('notifications.demo_recipient', ''),
|
||||
'dispatches' => $this->notifications->recent(25),
|
||||
'error' => $this->flash->get('site_error'),
|
||||
'success' => $this->flash->get('site_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendNotification(Request $request, Response $response): Response
|
||||
{
|
||||
if (!$this->can(Permission::NOTIFICATIONS_SEND)) {
|
||||
return $this->deny($response, 'Vous ne pouvez pas envoyer de notifications.');
|
||||
}
|
||||
|
||||
$data = (array) $request->getParsedBody();
|
||||
$recipient = trim((string) ($data['recipient'] ?? ''));
|
||||
$subject = trim((string) ($data['subject'] ?? 'Notification Netslim Blog'));
|
||||
|
||||
if ($recipient === '' || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->flash->set('site_error', 'Veuillez fournir une adresse email valide.');
|
||||
|
||||
return $response->withHeader('Location', '/admin/notifications')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->notifications->sendTemplate(
|
||||
to: $recipient,
|
||||
subject: $subject !== '' ? $subject : 'Notification Netslim Blog',
|
||||
template: '@Site/emails/demo-notification.twig',
|
||||
context: [
|
||||
'siteTitle' => $this->settings->getString('site.title', $_ENV['APP_NAME'] ?? 'Netslim Blog'),
|
||||
'siteTagline' => $this->settings->getString('site.tagline', ''),
|
||||
'sentAt' => (new \DateTimeImmutable())->format('d/m/Y H:i'),
|
||||
'appUrl' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
|
||||
],
|
||||
notificationKey: 'site.demo-notification',
|
||||
);
|
||||
|
||||
$this->auditLogger->record(
|
||||
action: 'notification.sent',
|
||||
resourceType: 'notification',
|
||||
resourceId: $recipient,
|
||||
actorUserId: $this->sessionManager->getUserId(),
|
||||
context: ['subject' => $subject],
|
||||
);
|
||||
$this->flash->set('site_success', 'La notification de démonstration a été envoyée.');
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger?->error('Demo notification failed', [
|
||||
'recipient' => $recipient,
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
]);
|
||||
$this->flash->set('site_error', 'L\'envoi de la notification a échoué. Vérifiez la configuration mail.');
|
||||
}
|
||||
|
||||
return $response->withHeader('Location', '/admin/notifications')->withStatus(302);
|
||||
}
|
||||
|
||||
private function can(string $permission): bool
|
||||
{
|
||||
return $this->authorization->canRole($this->currentRole(), $permission);
|
||||
}
|
||||
|
||||
private function currentRole(): string
|
||||
{
|
||||
if ($this->sessionManager->isAdmin()) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if ($this->sessionManager->isEditor()) {
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
return 'user';
|
||||
}
|
||||
|
||||
private function deny(Response $response, string $message): Response
|
||||
{
|
||||
$this->flash->set('site_error', $message);
|
||||
|
||||
return $response->withHeader('Location', '/admin')->withStatus(302);
|
||||
}
|
||||
}
|
||||
43
src/Site/UI/Templates/admin/audit-log.twig
Normal file
43
src/Site/UI/Templates/admin/audit-log.twig
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}Journal d’audit – {{ site.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '@Kernel/partials/_admin_page_header.twig' with { title: 'Journal d’audit' } %}
|
||||
|
||||
{% if entries is not empty %}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Ressource</th>
|
||||
<th>Auteur</th>
|
||||
<th>Contexte</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td data-label="Action"><strong>{{ entry.action }}</strong></td>
|
||||
<td data-label="Ressource">{{ entry.resourceType }} / {{ entry.resourceId }}</td>
|
||||
<td data-label="Auteur">{{ entry.actorUserId ?? '—' }}</td>
|
||||
<td data-label="Contexte">
|
||||
{% if entry.context %}
|
||||
<code class="admin-table__code">{{ entry.context|json_encode(constant('JSON_UNESCAPED_UNICODE')) }}</code>
|
||||
{% else %}
|
||||
<span class="admin-table__muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Date">{{ entry.createdAt|date('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% include '@Kernel/partials/_empty_state.twig' with {
|
||||
title: 'Aucune entrée enregistrée',
|
||||
message: 'Le journal d’audit se remplira après les premières actions d’administration.'
|
||||
} %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
63
src/Site/UI/Templates/admin/dashboard.twig
Normal file
63
src/Site/UI/Templates/admin/dashboard.twig
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}Administration – {{ site.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '@Kernel/partials/_admin_page_header.twig' with {
|
||||
title: 'Administration du blog',
|
||||
intro: 'Ce tableau de bord démontre l’utilisation des modules Settings, Authorization, AuditLog et Notifications de netslim-core.'
|
||||
} %}
|
||||
|
||||
{% include '@Kernel/partials/_flash_messages.twig' with { error: flashError|default(null), success: flashSuccess|default(null) } %}
|
||||
|
||||
<div class="card-list card-list--contained">
|
||||
<article class="card">
|
||||
<div class="card__content">
|
||||
<div class="card__body">
|
||||
<h2 class="card__title">Réglages du site</h2>
|
||||
<p class="card__excerpt">Titre : <strong>{{ settingsPreview.title }}</strong><br>Baseline : {{ settingsPreview.tagline ?: '—' }}</p>
|
||||
</div>
|
||||
{% if permissions.settings %}
|
||||
<div class="card__actions"><a class="card__actions-link" href="/admin/settings">Modifier les réglages →</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="card__content">
|
||||
<div class="card__body">
|
||||
<h2 class="card__title">Permissions</h2>
|
||||
<p class="card__excerpt">
|
||||
Contenu : <strong>{{ permissions.content ? 'autorisé' : 'interdit' }}</strong><br>
|
||||
Réglages : <strong>{{ permissions.settings ? 'autorisé' : 'interdit' }}</strong><br>
|
||||
Audit : <strong>{{ permissions.auditLog ? 'autorisé' : 'interdit' }}</strong><br>
|
||||
Notifications : <strong>{{ permissions.notifications ? 'autorisé' : 'interdit' }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if permissions.auditLog %}
|
||||
<section class="section">
|
||||
<h2>Activité récente</h2>
|
||||
{% if recentAuditEntries is not empty %}
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Action</th><th>Ressource</th><th>Auteur</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in recentAuditEntries %}
|
||||
<tr>
|
||||
<td data-label="Action"><strong>{{ entry.action }}</strong></td>
|
||||
<td data-label="Ressource">{{ entry.resourceType }} / {{ entry.resourceId }}</td>
|
||||
<td data-label="Auteur">{{ entry.actorUserId ?? '—' }}</td>
|
||||
<td data-label="Date">{{ entry.createdAt|date('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% include '@Kernel/partials/_empty_state.twig' with { title: 'Aucune entrée d\'audit', message: 'Le journal d\'audit se remplira au fil des actions d’administration.' } %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
63
src/Site/UI/Templates/admin/notifications.twig
Normal file
63
src/Site/UI/Templates/admin/notifications.twig
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "@Kernel/layout.twig" %}
|
||||
|
||||
{% block title %}Notifications – {{ site.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '@Kernel/partials/_admin_page_header.twig' with {
|
||||
title: 'Notifications transactionnelles',
|
||||
intro: 'Cette page démontre le module Notifications du core avec un envoi manuel et l’historique des dispatches.'
|
||||
} %}
|
||||
{% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %}
|
||||
|
||||
<form method="post" action="/admin/notifications/send" class="form-container">
|
||||
{% include '@Kernel/partials/_csrf_fields.twig' %}
|
||||
<div class="form-grid form-grid--stacked">
|
||||
<label class="form-container__label">
|
||||
Destinataire
|
||||
<input class="form-container__input" type="email" name="recipient" value="{{ defaultRecipient }}" required>
|
||||
</label>
|
||||
<label class="form-container__label">
|
||||
Sujet
|
||||
<input class="form-container__input" type="text" name="subject" value="Notification de démonstration – {{ site.title }}" required>
|
||||
</label>
|
||||
</div>
|
||||
{% include '@Kernel/partials/_admin_form_actions.twig' with {
|
||||
primary_label: 'Envoyer la notification',
|
||||
secondary_href: '/admin',
|
||||
secondary_label: 'Retour au tableau de bord'
|
||||
} %}
|
||||
</form>
|
||||
|
||||
{% if dispatches is not empty %}
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Destinataire</th>
|
||||
<th>Sujet</th>
|
||||
<th>Template</th>
|
||||
<th>Statut</th>
|
||||
<th>Créé le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for dispatch in dispatches %}
|
||||
<tr>
|
||||
<td data-label="Destinataire">{{ dispatch.recipient }}</td>
|
||||
<td data-label="Sujet"><strong>{{ dispatch.subject }}</strong></td>
|
||||
<td data-label="Template"><code class="admin-table__code">{{ dispatch.template }}</code></td>
|
||||
<td data-label="Statut">
|
||||
<strong>{{ dispatch.status }}</strong>
|
||||
{% if dispatch.errorMessage %}<br><span class="admin-table__muted">{{ dispatch.errorMessage }}</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="Créé le">{{ dispatch.createdAt|date('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{% include '@Kernel/partials/_empty_state.twig' with {
|
||||
title: 'Aucune notification envoyée',
|
||||
message: 'Envoyez un premier email de démonstration pour remplir l’historique.'
|
||||
} %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user