commit 42a4ba3e9aa87c45a3a4f0714f655756f15dcf62 Author: julien Date: Fri Mar 20 22:16:20 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5aa552c --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..749cc8a --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0dd7d15 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1b8e9a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..e43d2d5 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,36 @@ + 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']], + ]); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d00e15d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contribuer à netslim-blog + +Merci de contribuer au projet. + +## Règles simples + +- une évolution du domaine `Post` ou du module `Site` se fait dans ce dépôt ; +- une évolution transverse (`Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy`, `Media`) se fait dans `netslim-core` ; +- tout changement métier doit être accompagné de tests ciblés ; +- toute modification de comportement visible doit s’accompagner d’une mise à jour documentaire si elle change la compréhension du projet. + +## Avant d’ouvrir une MR + +```bash +composer qa +composer frontend:build +``` + +Fais aussi une passe manuelle si tu touches au back-office : +- `/admin` +- `/admin/settings` +- `/admin/audit-log` +- `/admin/notifications` +- `/admin/posts` +- `/admin/media` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3fed6e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c7ef7e --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# netslim-blog + +`netslim-blog` est une application blog construite au-dessus de `netslim-core`. + +Le dépôt fournit : +- le domaine métier `Post` ; +- un module applicatif `Site` qui intègre les briques transverses du core ; +- les templates, assets et points d’entrée HTTP du projet ; +- la configuration applicative (`config/modules.php`). + +## Dépendance vers netslim-core + +Le projet consomme `netig/netslim-core` depuis le dépôt Git en HTTPS. + +```json +{ + "repositories": [ + { + "type": "vcs", + "url": "https://git.netig.net/netig/netslim-core.git" + } + ], + "require": { + "netig/netslim-core": "^0.3@dev" + } +} +``` + +## Démonstration des modules du core + +L’application active et démontre les modules suivants : +- `Identity` pour l’authentification et l’autorisation fine ; +- `Settings` pour les réglages persistants du site ; +- `AuditLog` pour tracer les actions d’administration ; +- `Notifications` pour l’envoi et l’historique d’emails transactionnels ; +- `Taxonomy` pour la classification des contenus ; +- `Media` pour la médiathèque ; +- `Post` pour le domaine blog local. + +Les pages d’administration transverses se trouvent sous : +- `/admin` +- `/admin/settings` +- `/admin/audit-log` +- `/admin/notifications` + +## Démarrage rapide + +### Local (PHP intégré + assets compilés localement) + +```bash +composer install +composer frontend:install +composer frontend:build +cp .env.example .env +composer provision +composer start +``` + +### Docker + +```bash +cp .env.example .env +docker compose up -d --build +docker compose run --rm provision +``` + +## Composition active + +Le manifeste applicatif est défini dans `config/modules.php`. + +Il active : +- `KernelModule` +- `IdentityModule` +- `SettingsModule` +- `AuditLogModule` +- `NotificationsModule` +- `TaxonomyModule` +- `MediaModule` +- `SiteModule` +- `PostModule` + +## Documentation + +- `docs/APPLICATION.md` présente l’application livrée ; +- `docs/ARCHITECTURE.md` décrit la frontière entre le blog et `netslim-core` ; +- `docs/DEVELOPMENT.md` sert de guide quotidien ; +- `docs/FRONTEND.md` couvre Twig, SCSS et JavaScript ; +- `docs/DEPLOYMENT.md` couvre Docker et les répertoires persistants ; +- `netslim-core/docs/PUBLIC_API.md` reste la référence sur les points d’intégration stables du package partagé. + +Les tests et scripts CLI initialisent explicitement les runtime paths du blog, de sorte que le core résolve toujours le manifest de modules et les répertoires persistants contre ce projet et non contre `vendor/`. diff --git a/assets/js/media-admin.js b/assets/js/media-admin.js new file mode 100644 index 0000000..36d7798 --- /dev/null +++ b/assets/js/media-admin.js @@ -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 '/g, '>') + '" alt="" data-media-id="' + Number(mediaId) + '">'; + } + + function flashButtonLabel(button, message, isError) { + if (!button) { + return; + } + + if (!button.dataset.originalLabel) { + button.dataset.originalLabel = button.textContent.trim(); + } + + if (button.dataset.restoreTimerId) { + window.clearTimeout(Number(button.dataset.restoreTimerId)); + } + + button.textContent = message; + + if (isError) { + button.classList.add('btn--danger'); + } + + button.dataset.restoreTimerId = String(window.setTimeout(function () { + button.textContent = button.dataset.originalLabel || ''; + button.classList.remove('btn--danger'); + delete button.dataset.restoreTimerId; + }, 1800)); + } + + async function handleUpload() { + if (!uploadForm || !uploadButton || !uploadInput) { + return; + } + + if (!uploadInput.files || uploadInput.files.length === 0) { + setFeedback('Sélectionnez une image avant de téléverser.', true); + return; + } + + uploadButton.disabled = true; + setFeedback('Téléversement en cours…', false); + + try { + var response = await fetch(uploadForm.dataset.uploadUrl || uploadForm.action || window.location.href, { + method: 'POST', + body: new FormData(uploadForm), + credentials: 'same-origin', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + var payload = await response.json(); + + if (!response.ok) { + throw new Error(payload.error || 'Le téléversement a échoué.'); + } + + setFeedback('Image téléversée. Rafraîchissement…', false); + window.location.reload(); + } catch (error) { + setFeedback(error instanceof Error ? error.message : 'Le téléversement a échoué.', true); + } finally { + uploadButton.disabled = false; + } + } + + if (uploadButton) { + uploadButton.addEventListener('click', function () { + handleUpload(); + }); + } + + page.addEventListener('click', async function (event) { + var button = event.target.closest('[data-media-action]'); + if (!button) { + return; + } + + var action = button.dataset.mediaAction; + var url = button.dataset.mediaUrl || ''; + var mediaId = Number(button.dataset.mediaId || '0'); + + if (action === 'insert-editor') { + if (isPickerMode && window.parent && window.parent !== window) { + window.parent.postMessage({ + type: 'netslim:media-selected', + url: url, + mediaId: mediaId, + html: buildImageHtml(url, mediaId) + }, window.location.origin); + } + return; + } + + try { + if (action === 'copy-url') { + await copyToClipboard(toAbsoluteUrl(url)); + flashButtonLabel(button, 'URL copiée.', false); + return; + } + + if (action === 'copy-html') { + await copyToClipboard(buildImageHtml(url, mediaId)); + flashButtonLabel(button, 'HTML copié.', false); + return; + } + } catch (error) { + flashButtonLabel(button, 'Copie impossible.', true); + setFeedback('Impossible de copier dans le presse-papiers.', true); + } + }); +})(window, document); diff --git a/assets/js/post-editor-media-picker.js b/assets/js/post-editor-media-picker.js new file mode 100644 index 0000000..2f57f30 --- /dev/null +++ b/assets/js/post-editor-media-picker.js @@ -0,0 +1,147 @@ +(function (window, document, $) { + 'use strict'; + + if (!$ || typeof $.fn.trumbowyg !== 'function') { + return; + } + + var editorElement = document.getElementById('editor'); + var modal = document.getElementById('media-picker-modal'); + var closeButton = document.getElementById('media-picker-close'); + var frame = document.getElementById('media-picker-frame'); + + if (!editorElement) { + return; + } + + var $editor = $(editorElement); + var previousActiveElement = null; + + function escapeHtmlAttribute(value) { + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + function createMediaHtml(url, mediaId) { + return ''; + } + + 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); diff --git a/assets/scss/base/_reset.scss b/assets/scss/base/_reset.scss new file mode 100644 index 0000000..f7fc959 --- /dev/null +++ b/assets/scss/base/_reset.scss @@ -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; +} diff --git a/assets/scss/base/_typography.scss b/assets/scss/base/_typography.scss new file mode 100644 index 0000000..eddb7f8 --- /dev/null +++ b/assets/scss/base/_typography.scss @@ -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; +} diff --git a/assets/scss/components/_admin-create.scss b/assets/scss/components/_admin-create.scss new file mode 100644 index 0000000..92c440e --- /dev/null +++ b/assets/scss/components/_admin-create.scss @@ -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; +} diff --git a/assets/scss/components/_alert.scss b/assets/scss/components/_alert.scss new file mode 100644 index 0000000..ddae5ff --- /dev/null +++ b/assets/scss/components/_alert.scss @@ -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; + } +} diff --git a/assets/scss/components/_badge.scss b/assets/scss/components/_badge.scss new file mode 100644 index 0000000..6502d3b --- /dev/null +++ b/assets/scss/components/_badge.scss @@ -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; + } +} diff --git a/assets/scss/components/_button.scss b/assets/scss/components/_button.scss new file mode 100644 index 0000000..9f4c1f4 --- /dev/null +++ b/assets/scss/components/_button.scss @@ -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; + } +} diff --git a/assets/scss/components/_card.scss b/assets/scss/components/_card.scss new file mode 100644 index 0000000..b2c6d21 --- /dev/null +++ b/assets/scss/components/_card.scss @@ -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 +.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; + } +} diff --git a/assets/scss/components/_empty-state.scss b/assets/scss/components/_empty-state.scss new file mode 100644 index 0000000..5742d4e --- /dev/null +++ b/assets/scss/components/_empty-state.scss @@ -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; + } +} diff --git a/assets/scss/components/_form-container.scss b/assets/scss/components/_form-container.scss new file mode 100644 index 0000000..4fd3eef --- /dev/null +++ b/assets/scss/components/_form-container.scss @@ -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; +} diff --git a/assets/scss/components/_media-picker-modal.scss b/assets/scss/components/_media-picker-modal.scss new file mode 100644 index 0000000..6309a57 --- /dev/null +++ b/assets/scss/components/_media-picker-modal.scss @@ -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; +} diff --git a/assets/scss/components/_pagination.scss b/assets/scss/components/_pagination.scss new file mode 100644 index 0000000..827624a --- /dev/null +++ b/assets/scss/components/_pagination.scss @@ -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; + } +} diff --git a/assets/scss/components/_rich-text.scss b/assets/scss/components/_rich-text.scss new file mode 100644 index 0000000..a31443f --- /dev/null +++ b/assets/scss/components/_rich-text.scss @@ -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; + } +} diff --git a/assets/scss/components/_search-bar.scss b/assets/scss/components/_search-bar.scss new file mode 100644 index 0000000..4ccd8fa --- /dev/null +++ b/assets/scss/components/_search-bar.scss @@ -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%; + } +} diff --git a/assets/scss/components/_upload.scss b/assets/scss/components/_upload.scss new file mode 100644 index 0000000..5a0f782 --- /dev/null +++ b/assets/scss/components/_upload.scss @@ -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; + } +} diff --git a/assets/scss/core/_mixins.scss b/assets/scss/core/_mixins.scss new file mode 100644 index 0000000..cb0b868 --- /dev/null +++ b/assets/scss/core/_mixins.scss @@ -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; +} diff --git a/assets/scss/core/_variables.scss b/assets/scss/core/_variables.scss new file mode 100644 index 0000000..47423a3 --- /dev/null +++ b/assets/scss/core/_variables.scss @@ -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; diff --git a/assets/scss/layout/_picker-layout.scss b/assets/scss/layout/_picker-layout.scss new file mode 100644 index 0000000..107e38b --- /dev/null +++ b/assets/scss/layout/_picker-layout.scss @@ -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; +} diff --git a/assets/scss/layout/_site-footer.scss b/assets/scss/layout/_site-footer.scss new file mode 100644 index 0000000..ea5f497 --- /dev/null +++ b/assets/scss/layout/_site-footer.scss @@ -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; +} diff --git a/assets/scss/layout/_site-header.scss b/assets/scss/layout/_site-header.scss new file mode 100644 index 0000000..6e4576e --- /dev/null +++ b/assets/scss/layout/_site-header.scss @@ -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 +.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; + } +} diff --git a/assets/scss/layout/_site-main.scss b/assets/scss/layout/_site-main.scss new file mode 100644 index 0000000..e4ac085 --- /dev/null +++ b/assets/scss/layout/_site-main.scss @@ -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; +} diff --git a/assets/scss/main.scss b/assets/scss/main.scss new file mode 100644 index 0000000..cfafa81 --- /dev/null +++ b/assets/scss/main.scss @@ -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"; diff --git a/assets/scss/modules/post/_listing.scss b/assets/scss/modules/post/_listing.scss new file mode 100644 index 0000000..2f94599 --- /dev/null +++ b/assets/scss/modules/post/_listing.scss @@ -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 , 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; +} diff --git a/assets/scss/modules/post/_post.scss b/assets/scss/modules/post/_post.scss new file mode 100644 index 0000000..6aff285 --- /dev/null +++ b/assets/scss/modules/post/_post.scss @@ -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; +} diff --git a/assets/scss/modules/shared/_admin.scss b/assets/scss/modules/shared/_admin.scss new file mode 100644 index 0000000..f0ec8b1 --- /dev/null +++ b/assets/scss/modules/shared/_admin.scss @@ -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; + } +} diff --git a/assets/scss/modules/shared/_error-page.scss b/assets/scss/modules/shared/_error-page.scss new file mode 100644 index 0000000..70a3c7f --- /dev/null +++ b/assets/scss/modules/shared/_error-page.scss @@ -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; +} diff --git a/assets/scss/utilities/_inline.scss b/assets/scss/utilities/_inline.scss new file mode 100644 index 0000000..89e4f23 --- /dev/null +++ b/assets/scss/utilities/_inline.scss @@ -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; +} diff --git a/bin/provision.php b/bin/provision.php new file mode 100644 index 0000000..38112eb --- /dev/null +++ b/bin/provision.php @@ -0,0 +1,15 @@ +#!/usr/bin/env php +initializeInfrastructure(); +Provisioner::run($container->get(\PDO::class)); + +fwrite(STDOUT, "Provisioning termine.\n"); diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..34f8df2 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,7 @@ + /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"] diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..dd33aa6 --- /dev/null +++ b/docker/nginx/default.conf @@ -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; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..82b6d2f --- /dev/null +++ b/docker/php/Dockerfile @@ -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"] diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100644 index 0000000..fea5328 --- /dev/null +++ b/docker/php/entrypoint.sh @@ -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 "$@" diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..f571868 --- /dev/null +++ b/docker/php/php.ini @@ -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 diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md new file mode 100644 index 0000000..810bac8 --- /dev/null +++ b/docs/APPLICATION.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c43b98e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,46 @@ +# Architecture de netslim-blog + +`netslim-blog` est une application qui consomme `netslim-core` et ajoute deux modules locaux : +- `Post` pour le domaine éditorial ; +- `Site` pour les intégrations applicatives propres au blog. + +## Dépendances vers le core + +Le projet s’appuie sur : +- `Netig\Netslim\Kernel\...` +- `Netig\Netslim\Identity\...` +- `Netig\Netslim\Settings\...` +- `Netig\Netslim\AuditLog\...` +- `Netig\Netslim\Notifications\...` +- `Netig\Netslim\Taxonomy\...` +- `Netig\Netslim\Media\...` + +`ADMIN_HOME_PATH` pointe vers `/admin`, qui sert de tableau de bord au back-office du blog. + +## Frontière entre le dépôt et le core + +Dans ce dépôt, le code applicatif local est essentiellement : +- `src/Post/` +- `src/Site/` +- `templates/` +- `assets/` +- `config/` +- `public/` + +Le code transverse et les modules partagés vivent dans `vendor/netig/netslim-core/` après installation Composer. +Les répertoires runtime persistants (`var/`, `database/`, `public/media/`) restent toutefois ceux du projet blog lui-même. + +## Démonstration des capacités du core + +Le module `Site` démontre concrètement : +- `Settings` via les réglages de titre, baseline, meta description et pagination ; +- `Authorization` via les pages d’administration réservées aux permissions d’admin ; +- `AuditLog` via la traçabilité des actions sur les réglages, notifications et articles ; +- `Notifications` via une page d’envoi manuel et l’historique des dispatches. + +Le module `Post` reste propriétaire : +- des routes publiques et d’administration des contenus ; +- des migrations `posts`, `post_media`, `posts_fts` ; +- des usages concrets de `Taxonomy` et `Media`. + +En pratique, si une évolution relève de `Kernel`, `Identity`, `Settings`, `AuditLog`, `Notifications`, `Taxonomy` ou `Media`, elle doit être développée dans le dépôt `netslim-core`, puis intégrée ici via Composer. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..c61f818 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..e830963 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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//UI/Templates/` +- partials applicatifs transverses : `templates/Kernel/partials/` +- scripts d’écran : `assets/js/` + +## Points d’attention fonctionnels + +Le blog démontre les modules transverses du core : +- `Settings` pilote le titre, la baseline, la meta description, l’introduction et la pagination ; +- `AuditLog` trace les modifications de réglages, les notifications envoyées et les opérations CRUD sur les articles ; +- `Notifications` envoie un email de démonstration depuis `/admin/notifications` ; +- `Authorization` réserve les pages d’administration transverses aux administrateurs. + +## Vérifications avant push + +```bash +composer test +composer stan +composer cs:check +``` + +Et si le frontend a changé : + +```bash +composer frontend:build +``` + +Raccourci utile pour les vérifications backend : + +```bash +composer qa +``` + +Pour la couverture : + +```bash +composer test:coverage +``` diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 0000000..1314065 --- /dev/null +++ b/docs/FRONTEND.md @@ -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//UI/Templates/` +- partials de domaine : `src//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 `` 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. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md new file mode 100644 index 0000000..cc6a888 --- /dev/null +++ b/docs/MAINTENANCE.md @@ -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. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md new file mode 100644 index 0000000..f82bcce --- /dev/null +++ b/docs/ONBOARDING.md @@ -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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..377b8a1 --- /dev/null +++ b/docs/README.md @@ -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 | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f86c73f --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c6b91f --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1ff472c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..594790e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + + tests + + + + + + src + + + + diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..1b1f37a Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..c794863 --- /dev/null +++ b/public/index.php @@ -0,0 +1,31 @@ +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(); diff --git a/src/Post/Application/Command/CreatePostCommand.php b/src/Post/Application/Command/CreatePostCommand.php new file mode 100644 index 0000000..65867c1 --- /dev/null +++ b/src/Post/Application/Command/CreatePostCommand.php @@ -0,0 +1,18 @@ +postRepository->findAll($categoryId); + } + + /** @return PaginatedResult */ + 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 */ + 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 */ + 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); + } +} diff --git a/src/Post/Application/PostServiceInterface.php b/src/Post/Application/PostServiceInterface.php new file mode 100644 index 0000000..903c220 --- /dev/null +++ b/src/Post/Application/PostServiceInterface.php @@ -0,0 +1,97 @@ + + */ + public function findAll(?int $categoryId = null): array; + + /** + * Retourne une page d'articles, éventuellement filtrés par catégorie. + * + * @return PaginatedResult + */ + public function findPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult; + + /** + * Retourne les articles les plus récents. + * + * @return list + */ + public function findRecent(int $limit = 20): array; + + /** + * Retourne les articles d'un auteur, éventuellement filtrés par catégorie. + * + * @return list + */ + 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 + */ + 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 + */ + public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array; + + /** + * Retourne une page de résultats de recherche. + * + * @return PaginatedResult + */ + 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; +} diff --git a/src/Post/Application/UseCase/CreatePost.php b/src/Post/Application/UseCase/CreatePost.php new file mode 100644 index 0000000..c53af1c --- /dev/null +++ b/src/Post/Application/UseCase/CreatePost.php @@ -0,0 +1,55 @@ +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), + ); + } +} diff --git a/src/Post/Application/UseCase/DeletePost.php b/src/Post/Application/UseCase/DeletePost.php new file mode 100644 index 0000000..5962484 --- /dev/null +++ b/src/Post/Application/UseCase/DeletePost.php @@ -0,0 +1,25 @@ +postRepository->delete($id); + + if ($affected === 0) { + throw new NotFoundException('Article', $id); + } + } +} diff --git a/src/Post/Application/UseCase/UpdatePost.php b/src/Post/Application/UseCase/UpdatePost.php new file mode 100644 index 0000000..addf497 --- /dev/null +++ b/src/Post/Application/UseCase/UpdatePost.php @@ -0,0 +1,68 @@ +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), + ); + } +} diff --git a/src/Post/Domain/Entity/Post.php b/src/Post/Domain/Entity/Post.php new file mode 100644 index 0000000..e952931 --- /dev/null +++ b/src/Post/Domain/Entity/Post.php @@ -0,0 +1,253 @@ +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 $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'); + } + } +} diff --git a/src/Post/Domain/Repository/PostMediaUsageRepositoryInterface.php b/src/Post/Domain/Repository/PostMediaUsageRepositoryInterface.php new file mode 100644 index 0000000..8136017 --- /dev/null +++ b/src/Post/Domain/Repository/PostMediaUsageRepositoryInterface.php @@ -0,0 +1,48 @@ + $mediaIds Identifiants des médias à compter. + * @return array 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 + */ + 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 $mediaIds Identifiants des médias à inspecter. + * @return array> 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 $mediaIds Identifiants de médias extraits du contenu de l'article. + */ + public function syncPostMedia(int $postId, array $mediaIds): void; +} diff --git a/src/Post/Domain/Repository/PostRepositoryInterface.php b/src/Post/Domain/Repository/PostRepositoryInterface.php new file mode 100644 index 0000000..6cde6b0 --- /dev/null +++ b/src/Post/Domain/Repository/PostRepositoryInterface.php @@ -0,0 +1,107 @@ + + */ + public function findAll(?int $categoryId = null): array; + + /** + * Retourne une page d'articles, éventuellement filtrés par catégorie. + * + * @return list + */ + 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 + */ + public function findRecent(int $limit): array; + + /** + * Retourne les articles d'un auteur, éventuellement filtrés par catégorie. + * + * @return list + */ + public function findByUserId(int $userId, ?int $categoryId = null): array; + + /** + * Retourne une page d'articles d'un auteur. + * + * @return list + */ + 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 + */ + public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array; + + /** + * Retourne une page de résultats de recherche. + * + * @return list + */ + 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; +} diff --git a/src/Post/Domain/Service/PostMediaReferenceExtractorInterface.php b/src/Post/Domain/Service/PostMediaReferenceExtractorInterface.php new file mode 100644 index 0000000..f968ca7 --- /dev/null +++ b/src/Post/Domain/Service/PostMediaReferenceExtractorInterface.php @@ -0,0 +1,18 @@ + + */ + public function extractMediaIds(string $html): array; +} diff --git a/src/Post/Domain/Service/PostSlugGenerator.php b/src/Post/Domain/Service/PostSlugGenerator.php new file mode 100644 index 0000000..6ba1a36 --- /dev/null +++ b/src/Post/Domain/Service/PostSlugGenerator.php @@ -0,0 +1,31 @@ +postId; + } + + public function getPostTitle(): string + { + return $this->postTitle; + } + + public function getPostEditPath(): string + { + return $this->postEditPath; + } +} diff --git a/src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php b/src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php new file mode 100644 index 0000000..ec23163 --- /dev/null +++ b/src/Post/Infrastructure/HtmlPostMediaReferenceExtractor.php @@ -0,0 +1,74 @@ + */ + 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 = '
' . $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; + } +} diff --git a/src/Post/Infrastructure/PdoPostMediaUsageRepository.php b/src/Post/Infrastructure/PdoPostMediaUsageRepository.php new file mode 100644 index 0000000..b543e4c --- /dev/null +++ b/src/Post/Infrastructure/PdoPostMediaUsageRepository.php @@ -0,0 +1,173 @@ +db->prepare('SELECT COUNT(*) FROM post_media WHERE media_id = :media_id'); + $stmt->execute([':media_id' => $mediaId]); + + return (int) $stmt->fetchColumn(); + } + + /** + * @param list $mediaIds + * @return array + */ + 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 */ + 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 $mediaIds + * @return array> + */ + 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 $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 $mediaIds + * @return list + */ + 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 $mediaIds + */ + private function buildPlaceholders(array $mediaIds): string + { + return implode(', ', array_fill(0, count($mediaIds), '?')); + } +} diff --git a/src/Post/Infrastructure/PdoPostRepository.php b/src/Post/Infrastructure/PdoPostRepository.php new file mode 100644 index 0000000..a56d5e3 --- /dev/null +++ b/src/Post/Infrastructure/PdoPostRepository.php @@ -0,0 +1,347 @@ +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 $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 $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> $rows + * @return Post[] */ + private function hydratePosts(array $rows): array + { + return array_map(static fn (array $row): Post => Post::fromArray($row), $rows); + } +} diff --git a/src/Post/Infrastructure/PdoTaxonUsageChecker.php b/src/Post/Infrastructure/PdoTaxonUsageChecker.php new file mode 100644 index 0000000..9028e44 --- /dev/null +++ b/src/Post/Infrastructure/PdoTaxonUsageChecker.php @@ -0,0 +1,24 @@ +db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id'); + $stmt->execute([':category_id' => $taxonId]); + + return (int) $stmt->fetchColumn() > 0; + } +} diff --git a/src/Post/Infrastructure/PostMediaUsageReader.php b/src/Post/Infrastructure/PostMediaUsageReader.php new file mode 100644 index 0000000..e8d5d50 --- /dev/null +++ b/src/Post/Infrastructure/PostMediaUsageReader.php @@ -0,0 +1,73 @@ +postMediaUsageRepository->countUsages($mediaId); + } + + /** + * @param list $mediaIds + * @return array + */ + public function countUsagesByMediaIds(array $mediaIds): array + { + return $this->postMediaUsageRepository->countUsagesByMediaIds($mediaIds); + } + + /** @return list */ + public function findUsages(int $mediaId, int $limit = 5): array + { + return $this->mapUsageReferences($this->postMediaUsageRepository->findUsages($mediaId, $limit)); + } + + /** + * @param list $mediaIds + * @return array> + */ + 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 $references + * @return list + */ + private function mapUsageReferences(array $references): array + { + return array_map( + static fn (PostMediaUsageReference $reference): MediaUsageReference => new MediaUsageReference( + $reference->getPostId(), + $reference->getPostTitle(), + $reference->getPostEditPath(), + ), + $references, + ); + } +} diff --git a/src/Post/Infrastructure/PostSearchIndexer.php b/src/Post/Infrastructure/PostSearchIndexer.php new file mode 100644 index 0000000..62be07e --- /dev/null +++ b/src/Post/Infrastructure/PostSearchIndexer.php @@ -0,0 +1,26 @@ +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) + "); + } +} diff --git a/src/Post/Infrastructure/dependencies.php b/src/Post/Infrastructure/dependencies.php new file mode 100644 index 0000000..067058f --- /dev/null +++ b/src/Post/Infrastructure/dependencies.php @@ -0,0 +1,45 @@ + 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', + ); + }), +]; diff --git a/src/Post/Migrations/400_post_schema.php b/src/Post/Migrations/400_post_schema.php new file mode 100644 index 0000000..7a0d170 --- /dev/null +++ b/src/Post/Migrations/400_post_schema.php @@ -0,0 +1,89 @@ + " + 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; + ", +]; diff --git a/src/Post/PostModule.php b/src/Post/PostModule.php new file mode 100644 index 0000000..cd03935 --- /dev/null +++ b/src/Post/PostModule.php @@ -0,0 +1,53 @@ + $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); + } +} diff --git a/src/Post/UI/Http/PostController.php b/src/Post/UI/Http/PostController.php new file mode 100644 index 0000000..1f80d39 --- /dev/null +++ b/src/Post/UI/Http/PostController.php @@ -0,0 +1,346 @@ +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 $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 $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 $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 $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 $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; + } +} diff --git a/src/Post/UI/Http/Request/PostFormRequest.php b/src/Post/UI/Http/Request/PostFormRequest.php new file mode 100644 index 0000000..4cb1b03 --- /dev/null +++ b/src/Post/UI/Http/Request/PostFormRequest.php @@ -0,0 +1,33 @@ + $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, + ); + } +} diff --git a/src/Post/UI/Http/Routes.php b/src/Post/UI/Http/Routes.php new file mode 100644 index 0000000..4fa253b --- /dev/null +++ b/src/Post/UI/Http/Routes.php @@ -0,0 +1,31 @@ + $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); + } +} diff --git a/src/Post/UI/Http/RssController.php b/src/Post/UI/Http/RssController.php new file mode 100644 index 0000000..dd39d58 --- /dev/null +++ b/src/Post/UI/Http/RssController.php @@ -0,0 +1,60 @@ +postService->findRecent(self::FEED_LIMIT); + $baseUrl = $this->appUrl; + + $xml = new \SimpleXMLElement(''); + $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'); + } +} diff --git a/src/Post/UI/Templates/admin/form.twig b/src/Post/UI/Templates/admin/form.twig new file mode 100644 index 0000000..a713c5f --- /dev/null +++ b/src/Post/UI/Templates/admin/form.twig @@ -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 %} + +{% endblock %} + +{% block content %} +
+ {% 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' + } %} + +
+ {% include '@Kernel/partials/_flash_messages.twig' with { error: error|default(null), success: success|default(null) } %} + +
+ {% include '@Kernel/partials/_csrf_fields.twig' %} + + {% if post is defined and post is not null %} +

+ +

+ {% endif %} + +

+ +

+ + {% if post is defined and post is not null and post.id > 0 %} +

+ + (URL actuelle : /article/{{ post.storedSlug }}) +

+ {% endif %} + +

+ +

+ +

+ + +

+ + + + {% 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' + } %} +
+ + {% if post is defined and post is not null and post.id > 0 %} + + {% endif %} +
+
+{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/src/Post/UI/Templates/admin/index.twig b/src/Post/UI/Templates/admin/index.twig new file mode 100644 index 0000000..39c4006 --- /dev/null +++ b/src/Post/UI/Templates/admin/index.twig @@ -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 %} + + + + + + + + + + + + + {% for post in posts %} + + + + + + + + + {% endfor %} + +
TitreCatégorieAuteurCréé leModifié leActions
{{ post.title }} + {% if post.categoryName %} + {% include '@Kernel/partials/_badge.twig' with { + label: post.categoryName, + modifier: 'category', + href: '/admin/posts?categorie=' ~ post.categorySlug + } %} + {% else %} + + {% endif %} + {{ post.authorUsername ?? 'inconnu' }}{{ post.createdAt|date("d/m/Y H:i") }}{{ post.updatedAt|date("d/m/Y H:i") }} +
+ Éditer + + {% include '@Kernel/partials/_admin_delete_form.twig' with { + action: '/admin/posts/delete/' ~ post.id, + confirm: 'Supprimer cet article ?' + } %} +
+
+ +{% 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 %} diff --git a/src/Post/UI/Templates/detail.twig b/src/Post/UI/Templates/detail.twig new file mode 100644 index 0000000..a7170e0 --- /dev/null +++ b/src/Post/UI/Templates/detail.twig @@ -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) %} + + + + + +{% if thumb %} + +{% endif %} +{% endblock %} + +{% block content %} +
+

{{ post.title }}

+ + + + {% if post.updatedAt != post.createdAt %} +
+ Mis à jour le {{ post.updatedAt|date("d/m/Y à H:i") }} +
+ {% endif %} + +
+ {{ post.content|raw }} +
+ +
+

+ ← Retour aux articles +

+
+{% endblock %} diff --git a/src/Post/UI/Templates/home.twig b/src/Post/UI/Templates/home.twig new file mode 100644 index 0000000..467fb30 --- /dev/null +++ b/src/Post/UI/Templates/home.twig @@ -0,0 +1,85 @@ +{% extends "@Kernel/layout.twig" %} + +{% block title %}{{ site.title }}{% endblock %} + +{% block meta %} + + + + + +{% endblock %} + +{% block content %} +
+

{{ site.title }}

+ {% if site.homeIntro %}

{{ site.homeIntro }}

{% endif %} +
+ +{% 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=' +} %} + +
+{% for post in posts %} + {% set thumb = post_thumbnail(post) %} + +{% 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 %} +
+ +{% include '@Kernel/partials/_pagination.twig' with { pagination: pagination } %} +{% endblock %} diff --git a/src/Post/UI/Templates/partials/_category_filter.twig b/src/Post/UI/Templates/partials/_category_filter.twig new file mode 100644 index 0000000..256a0f9 --- /dev/null +++ b/src/Post/UI/Templates/partials/_category_filter.twig @@ -0,0 +1,14 @@ +{% if categories is not empty %} + +{% endif %} diff --git a/src/Post/UI/Templates/partials/_search_form.twig b/src/Post/UI/Templates/partials/_search_form.twig new file mode 100644 index 0000000..e9a01d5 --- /dev/null +++ b/src/Post/UI/Templates/partials/_search_form.twig @@ -0,0 +1,23 @@ + + +{% if searchQuery %} +

+ {% if totalPosts > 0 %} + {{ totalPosts }} résultat{{ totalPosts > 1 ? 's' : '' }} pour « {{ searchQuery }} » + {% else %} + Aucun résultat pour « {{ searchQuery }} » + {% endif %} +

+{% endif %} diff --git a/src/Post/UI/Twig/TwigPostExtension.php b/src/Post/UI/Twig/TwigPostExtension.php new file mode 100644 index 0000000..cfb3c9e --- /dev/null +++ b/src/Post/UI/Twig/TwigPostExtension.php @@ -0,0 +1,99 @@ + 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 = '