first commit

This commit is contained in:
julien
2026-03-20 22:16:20 +01:00
commit 42a4ba3e9a
136 changed files with 10141 additions and 0 deletions

61
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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 saccompagner dune mise à jour documentaire si elle change la compréhension du projet.
## Avant douvrir 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
View 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
View 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 dentré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
Lapplication active et démontre les modules suivants :
- `Identity` pour lauthentification et lautorisation fine ;
- `Settings` pour les réglages persistants du site ;
- `AuditLog` pour tracer les actions dadministration ;
- `Notifications` pour lenvoi et lhistorique demails transactionnels ;
- `Taxonomy` pour la classification des contenus ;
- `Media` pour la médiathèque ;
- `Post` pour le domaine blog local.
Les pages dadministration 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 lapplication 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 dinté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
View 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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;') + '" 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);

View 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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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);

View 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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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%;
}
}

View 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;
}
}

View 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;
}

View 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;

View 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;
}

View 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;
}

View 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;
}
}

View 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
View 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";

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 sappuie 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 dadministration réservées aux permissions dadmin ;
- `AuditLog` via la traçabilité des actions sur les réglages, notifications et articles ;
- `Notifications` via une page denvoi manuel et lhistorique des dispatches.
Le module `Post` reste propriétaire :
- des routes publiques et dadministration 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
View 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
View 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 dattention fonctionnels
Le blog démontre les modules transverses du core :
- `Settings` pilote le titre, la baseline, la meta description, lintroduction 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 dadministration 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
parameters:
level: 8
paths:
- src

25
phpunit.xml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

31
public/index.php Normal file
View 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();

View 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,
) {}
}

View 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,
) {}
}

View 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);
}
}

View 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;
}

View 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),
);
}
}

View 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);
}
}
}

View 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),
);
}
}

View 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 (1255 caractères)
* @param string $content Contenu HTML de l'article (165 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');
}
}
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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), '?'));
}
}

View 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);
}
}

View 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;
}
}

View 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,
);
}
}

View 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)
");
}
}

View 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',
);
}),
];

View 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
View 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);
}
}

View 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;
}
}

View 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,
);
}
}

View 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);
}
}

View 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');
}
}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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;
}
}

View 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 lutilisation 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é'),
};
}
}

View 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
View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,43 @@
{% extends "@Kernel/layout.twig" %}
{% block title %}Journal daudit {{ site.title }}{% endblock %}
{% block content %}
{% include '@Kernel/partials/_admin_page_header.twig' with { title: 'Journal daudit' } %}
{% 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 daudit se remplira après les premières actions dadministration.'
} %}
{% endif %}
{% endblock %}

View 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 lutilisation 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 dadministration.' } %}
{% endif %}
</section>
{% endif %}
{% endblock %}

View 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 lhistorique 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 lhistorique.'
} %}
{% endif %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More