feat : wizard multi-étapes, migrations contenu, versionnage semver (v1.2.1) #60
+63
-125
@@ -1,140 +1,78 @@
|
||||
# Changelog — varlog
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] — 2026-05-13
|
||||
Toutes les modifications notables sont documentées ici.
|
||||
Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnage [semver](https://semver.org/lang/fr/).
|
||||
|
||||
### Performances
|
||||
---
|
||||
|
||||
- **Cache multi-niveaux pour les vues d'articles** : temps de chargement réduit
|
||||
de +5 s à ~0,4 s sur 1 062 articles.
|
||||
- Mémoïsation de `getAll()` et `getSearchIndex()` dans la requête PHP
|
||||
(`$allCache`, `$searchIndexCache`) — évite les appels répétés.
|
||||
- Cache disque par article (`_cache/articles/{uuid}.json`) avec invalidation
|
||||
par comparaison `mtime` — 1 lecture au lieu de 2 par article.
|
||||
- Slug index (`_cache/slug_index.json`) : `getBySlug()` en O(1) sans scanner
|
||||
tous les articles ; construit depuis `search_index.json` en un seul fichier.
|
||||
- `getCategories()` et `$_allPublished` chargés depuis `search_index.json`
|
||||
au lieu de `getAll()` — 1 fichier lu quelle que soit la taille du catalogue.
|
||||
- `search_index.json` enrichi avec `cover`, `created_at`, `author` ; rebuild
|
||||
automatique si le format est obsolète.
|
||||
- `SearchEngine::scorePool()` : tokenise chaque article une seule fois pour
|
||||
N mots de titre (vs N passes séparées qui retokenisaient chaque article
|
||||
N fois et calculaient la similarité trigramme sur le contenu).
|
||||
- Le nombre de lectures de fichiers par vue d'article est désormais constant
|
||||
(~4), indépendamment du nombre total d'articles.
|
||||
- Documentation : `docs/cache-architecture.md`.
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-05-14
|
||||
|
||||
### Ajouté
|
||||
- Wizard multi-étapes pour la création (5 écrans) et l'édition (6 écrans) d'articles (#58)
|
||||
- Auto-sauvegarde en brouillon (debounce 3 s) avec indicateur visible
|
||||
- Étape tags : champ plat avec détection automatique depuis le texte (abréviations, CamelCase, noms propres)
|
||||
- Étape SEO : aperçu moteur de recherche en temps réel
|
||||
- Étape 6 (édition) : diff ligne à ligne avant confirmation
|
||||
- Plan Markdown dynamique (TOC) dans la colonne droite de l'éditeur
|
||||
- Titre extrait du premier `# …` du contenu Markdown (plus de champ titre séparé)
|
||||
- Système de migrations de contenu (`scripts/migrate_content.php`)
|
||||
- Mode maintenance automatique (`data/.maintenance` → page HTTP 503)
|
||||
- Migration `001` : ajout du titre Markdown dans les articles existants
|
||||
- Bouton "Mettre à jour" dans l'administration (sans accès CLI)
|
||||
- `UpdateChecker` : détection de mise à jour et migrations en attente
|
||||
- Bandeau d'alerte pour les administrateurs sur toutes les pages
|
||||
- Dashboard `/admin` : version déployée vs version disponible
|
||||
|
||||
### Modifié
|
||||
- `ArticleManager` : +6 méthodes pour les brouillons overlay
|
||||
- `lineDiff` : normalisation `\r\n` → `\n`, seuil relevé à 2 000 000, fallback ligne par ligne
|
||||
- `push.sh` : génère `public/version.txt` (numéro de version semver) à chaque release
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement.
|
||||
Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans
|
||||
`/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`.
|
||||
Le handler `add_files` détecte désormais le dépassement et affiche un message
|
||||
d'erreur explicite au lieu de rediriger sans rien faire.
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- **Réactions visiteurs** : trois boutons (👍 Utile / 🔥 Important / 🤔 À creuser)
|
||||
affichés sous chaque article. Toggle : recliquer retire la réaction. Accessible sans
|
||||
compte via un cookie UUID (`vl_vid`, 1 an, `HttpOnly`). Comportement async fetch avec
|
||||
fallback formulaire natif (compatible CSP `script-src 'self'`). Routes :
|
||||
`POST /react`. Table BDD : `article_reactions`.
|
||||
|
||||
- **Commentaires avec vérification email** : formulaire nom + email (non publié) +
|
||||
texte (2 000 caractères max). Protection honeypot + CSRF en session. Un code à
|
||||
6 chiffres est envoyé par email (expire 24 h) ; le commentaire est auto-publié au clic
|
||||
sur le lien de confirmation. Routes : `POST /comment`,
|
||||
`GET /verify-comment/<6chiffres>`. Table BDD : `comments`.
|
||||
|
||||
- **Modération commentaires** : onglet **Commentaires** dans `/admin/comments` listant
|
||||
tous les commentaires avec statut (vérifié / publié) et actions masquer/republier.
|
||||
Route : `POST /comment-moderate`.
|
||||
|
||||
- **Page de confirmation à l'enregistrement** : cliquer sur "Enregistrer" affiche une
|
||||
page intermédiaire avec le diff du contenu, le slug (déplacé ici depuis le formulaire,
|
||||
avec suggestion auto si le titre a changé), un commentaire de révision pré-rempli
|
||||
d'après les modifications détectées, et un aperçu SEO (snippet Google). La
|
||||
sauvegarde effective n'a lieu qu'après confirmation.
|
||||
|
||||
- **URLs propres** : toutes les routes internes migrent vers des chemins lisibles.
|
||||
Les anciennes URLs `/?action=…` restent fonctionnelles (compatibilité).
|
||||
| Ancienne URL | Nouvelle URL |
|
||||
|---|---|
|
||||
| `/?action=edit&uuid=<u>` | `/edit/<u>` |
|
||||
| `/?action=sources&uuid=<u>` | `/sources/<u>` |
|
||||
| `/?action=diff&uuid=<u>&rev=<n>` | `/diff/<u>/<n>` |
|
||||
| `/?action=create` | `/new` |
|
||||
| `/?action=admin[&tab=<t>]` | `/admin[/<t>]` |
|
||||
| `/?action=categories` | `/categories` |
|
||||
| `/?action=profile` | `/profile` |
|
||||
| `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… |
|
||||
| `/?action=regen_thumbs` | `/admin/regen-thumbs` |
|
||||
| `/?action=add_files&uuid=<u>` | `/files/<u>/add` |
|
||||
| `/?action=import_image&uuid=<u>` | `/import/<u>` |
|
||||
| `/?cat=<cat>` | `/categorie/<cat>` |
|
||||
| `/?cursor=<uuid>` | `/page/<uuid>` |
|
||||
|
||||
- **Moteur de recherche** : index trigram+substring pré-construit (`search_index.json`,
|
||||
reconstruit à chaque écriture), accessible depuis la navbar.
|
||||
|
||||
### Corrections
|
||||
|
||||
- **Métadonnées fichiers (sources)** : `addFileMeta()` ne sauvegardait pas l'auteur et
|
||||
l'URL source en raison d'un guard `file_exists()` trop strict — supprimé.
|
||||
- **Authentification OIDC** (`State invalide.`) : `session_start()` était appelé avant
|
||||
`bootstrap.php` dans les fichiers OIDC, écrasant les paramètres de cookie
|
||||
(`SameSite=Lax`, `Secure`, `HttpOnly`) — corrigé dans `start.php`, `callback.php`
|
||||
et `me.php`.
|
||||
- **Sidebar droite de l'article** : classe Bootstrap `flex-nowrap-lg` inexistante,
|
||||
remplacée par `flex-lg-nowrap` — la sidebar ne tombe plus en bas de page.
|
||||
- **Date d'affichage en liste** : `created_at` affiché à la place de `published_at`
|
||||
— corrigé avec fallback approprié.
|
||||
- **Formulaire d'édition** : "Fichiers existants" déplacé dans la colonne de droite ;
|
||||
attribution auteur/source étendue à tous les types de fichiers (pas seulement images).
|
||||
- **Historique des révisions** : plus de révision créée si le contenu et le titre
|
||||
sont inchangés. Ajout des boutons de suppression par révision et suppression globale.
|
||||
- **Canonical URL catégorie** : passe de `/?cat=…` à `/categorie/…`.
|
||||
- **Flux RSS** : `/rss` et `/rss.xml` redirigent en 301 vers `/feed` (URL
|
||||
canonique) ; les articles des catégories privées sont exclus du flux ;
|
||||
la description est convertie depuis Markdown en texte brut.
|
||||
- Diff étape 6 "violent" (tout supprimé/ajouté) dû aux fins de ligne `\r\n` du navigateur
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-09
|
||||
## [1.1.0] - 2026-05-13
|
||||
|
||||
### Fonctionnalités
|
||||
### Ajouté
|
||||
- **Réactions visiteurs** : boutons 👍 / 🔥 / 🤔 sous chaque article, toggle async avec fallback formulaire natif
|
||||
- **Commentaires avec vérification email** : code 6 chiffres, honeypot + CSRF, modération dans `/admin`
|
||||
- **URLs propres** : `/edit/<u>`, `/new`, `/admin`, `/categorie/<cat>`, `/files/<u>/add`, `/import/<u>`, etc.
|
||||
- **Moteur de recherche** : index trigramme+substring pré-construit, résultats scorés avec mise en évidence
|
||||
|
||||
- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` /
|
||||
`WebSite`), `noindex` sur les pages d'administration.
|
||||
- **Recherche** : page de résultats avec score de pertinence, mise en évidence des
|
||||
termes, lien vers la catégorie depuis les résultats.
|
||||
- **Support HEIC/HEIF** : conversion automatique en JPEG à l'upload.
|
||||
- **Support SVG** : upload autorisé, servi avec Content-Type correct.
|
||||
- **Avant-première** : article visible en liste mais verrouillé avant sa date de
|
||||
publication.
|
||||
- **Pagination curseur** : navigation par UUID de dernier article vu, sans offset SQL.
|
||||
- **Layout article 3 colonnes** : sidebar gauche (catégorie), contenu central,
|
||||
sidebar droite (pièces jointes, liens externes, articles liés).
|
||||
- **Import depuis URL** : téléchargement de fichiers distants avec extraction
|
||||
automatique des métadonnées (EXIF, OpenGraph, PDF).
|
||||
- **Gestion des pièces jointes** dans le formulaire d'édition, avec attribution
|
||||
auteur/source affichée dans la vue article.
|
||||
### Amélioré
|
||||
- **Cache multi-niveaux** : chargement réduit de ~5 s à ~0,4 s sur 1 000+ articles (mémoïsation, cache disque, slug index O(1))
|
||||
- **Upload fichiers** : détection et message d'erreur explicite pour les fichiers > limite PHP
|
||||
|
||||
### Corrections
|
||||
|
||||
- Login intégré dans `layout.php`, chemins CSS en absolu.
|
||||
- Redéclaration de `url()` dans `config.php` — fatal error corrigée.
|
||||
- Correction permissions `www-data` sur `data/`.
|
||||
### Corrigé
|
||||
- Métadonnées fichiers (`addFileMeta`) : guard `file_exists()` trop strict supprimé
|
||||
- Sidebar droite article : classe Bootstrap `flex-nowrap-lg` → `flex-lg-nowrap`
|
||||
- Flux RSS : exclusion catégories privées, redirection 301 `/rss` → `/feed`
|
||||
|
||||
---
|
||||
|
||||
## 2026-04 et antérieur
|
||||
## [1.0.0] - 2026-05-09
|
||||
|
||||
- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery.
|
||||
- Stockage des articles en fichiers Markdown (migration depuis base de données).
|
||||
- SSO via Keycloak/OIDC avec PKCE.
|
||||
- Images de couverture (liste, vue article, `og:image`).
|
||||
- Brouillons visibles uniquement par l'auteur.
|
||||
- Formulaire de contact (CSRF, honeypot, rate-limit).
|
||||
- Pages : mentions légales (LCEN/RGPD), licences, à propos.
|
||||
- Auto-hébergement Bootstrap 5, police Inter, favicon SVG.
|
||||
- Headers HTTP de sécurité, CSP stricte.
|
||||
### Ajouté
|
||||
- Moteur de blog PHP Folio — première release versionnée
|
||||
- Articles en Markdown avec fichiers attachés, liens externes, images de couverture
|
||||
- Authentification par lien magique envoyé par email (#29)
|
||||
- SSO via Keycloak/OIDC avec PKCE
|
||||
- Rôles, capacités et gestion des utilisateurs
|
||||
- Catégories avec swatches couleur générées algorithmiquement
|
||||
- Tags par type avec suggestions
|
||||
- SEO : canonical, `sitemap.xml`, `robots.txt`, JSON-LD, `og:image`
|
||||
- Avant-premières (articles futurs visibles aux utilisateurs autorisés)
|
||||
- Pagination curseur (sans offset SQL)
|
||||
- Import depuis URL (EXIF, OpenGraph, PDF)
|
||||
- Historique des révisions avec diff
|
||||
- Flux RSS (`/feed`) paginé avec autodiscovery
|
||||
- Formulaire de contact (CSRF, honeypot, rate-limit)
|
||||
- Pages légales (LCEN/RGPD), licences, à propos
|
||||
- Migrations SQL versionnées (`database/migrate.php`)
|
||||
- Système de déploiement par rsync
|
||||
|
||||
+16
-39
@@ -37,40 +37,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Génération slug automatique (étape 1 / création) ───────────────────
|
||||
var titleInput = document.getElementById('wz-title');
|
||||
var slugField = document.getElementById('slug');
|
||||
var slugPreview = document.getElementById('slug-preview');
|
||||
|
||||
function slugify(s) {
|
||||
var map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
|
||||
return s.toLowerCase()
|
||||
.replace(/[àâäéèêëîïôöùûüçæœ]/g, function(c) { return map[c] || c; })
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
if (titleInput && slugField) {
|
||||
if (slugField.value !== '') slugField._auto = false;
|
||||
titleInput.addEventListener('input', function () {
|
||||
if (slugField._auto !== false) {
|
||||
var gen = slugify(this.value);
|
||||
slugField.value = gen;
|
||||
if (slugPreview) slugPreview.textContent = gen;
|
||||
}
|
||||
});
|
||||
slugField.addEventListener('input', function () {
|
||||
this._auto = (this.value === '');
|
||||
if (slugPreview) slugPreview.textContent = this.value;
|
||||
});
|
||||
}
|
||||
var slugField = document.getElementById('slug');
|
||||
|
||||
// ─── Autosave ────────────────────────────────────────────────────────────
|
||||
var indicator = document.getElementById('autosave-indicator');
|
||||
if (indicator && uuid && autosaveUrl) {
|
||||
var timer = null;
|
||||
var titleEl = document.getElementById('wz-title');
|
||||
var contentEl = document.getElementById('wz-content');
|
||||
var timer = null;
|
||||
|
||||
function scheduleAutosave() {
|
||||
clearTimeout(timer);
|
||||
@@ -78,27 +50,32 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
async function doAutosave() {
|
||||
if (!titleEl || !contentEl) return;
|
||||
if (!ta) return;
|
||||
indicator.textContent = 'Sauvegarde…';
|
||||
try {
|
||||
var res = await fetch(autosaveUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({
|
||||
title: titleEl.value,
|
||||
content: contentEl.value,
|
||||
slug: slugField ? slugField.value : '',
|
||||
}),
|
||||
body: new URLSearchParams({ content: ta.value }),
|
||||
});
|
||||
var data = await res.json();
|
||||
indicator.textContent = data.ok ? 'Brouillon sauvegardé à ' + data.time : 'Erreur de sauvegarde';
|
||||
if (data.ok) {
|
||||
indicator.textContent = 'Sauvegardé à ' + data.time;
|
||||
// Mettre à jour le titre de la page si le serveur l'a extrait
|
||||
var pageTitle = document.getElementById('wz-page-title');
|
||||
if (pageTitle && data.title) {
|
||||
var prefix = pageTitle.dataset.prefix || '';
|
||||
pageTitle.textContent = prefix ? prefix + data.title : data.title;
|
||||
}
|
||||
} else {
|
||||
indicator.textContent = 'Erreur de sauvegarde';
|
||||
}
|
||||
} catch (err) {
|
||||
indicator.textContent = 'Erreur de sauvegarde';
|
||||
}
|
||||
}
|
||||
|
||||
if (titleEl) titleEl.addEventListener('input', scheduleAutosave);
|
||||
if (ta) ta.addEventListener('input', scheduleAutosave);
|
||||
if (ta) ta.addEventListener('input', scheduleAutosave);
|
||||
}
|
||||
|
||||
// ─── Insertion Markdown depuis miniatures ────────────────────────────────
|
||||
|
||||
+75
-40
@@ -26,6 +26,17 @@ require_once BASE_PATH . '/src/ArticleManager.php';
|
||||
|
||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
||||
|
||||
// ─── Mode maintenance ──────────────────────────────────────────────────────
|
||||
if (file_exists(BASE_PATH . '/data/.maintenance')) {
|
||||
http_response_code(503);
|
||||
header('Retry-After: 60');
|
||||
include BASE_PATH . '/templates/maintenance.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once BASE_PATH . '/src/UpdateChecker.php';
|
||||
$_updateChecker = new UpdateChecker(BASE_PATH . '/data', BASE_PATH);
|
||||
|
||||
$action = $_GET['action'] ?? 'list';
|
||||
$uuid = $_GET['uuid'] ?? '';
|
||||
$slug = $_GET['slug'] ?? '';
|
||||
@@ -512,28 +523,24 @@ switch ($action) {
|
||||
|
||||
case 1:
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$content = $_POST['content'] ?? '';
|
||||
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||||
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
|
||||
$postSlug = trim($_POST['slug'] ?? '');
|
||||
if ($title === '') {
|
||||
$errors[] = 'Le titre est obligatoire.';
|
||||
} else {
|
||||
if ($draft === null) {
|
||||
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
|
||||
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
|
||||
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
|
||||
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
|
||||
}
|
||||
if ($draft === null) {
|
||||
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
|
||||
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
|
||||
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
|
||||
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
|
||||
}
|
||||
$_SESSION['wizard_create'] = $uuid;
|
||||
} else {
|
||||
$articles->autosave($uuid, $title, $content, $postSlug);
|
||||
}
|
||||
header('Location: /new/' . rawurlencode($uuid) . '/2');
|
||||
exit;
|
||||
$_SESSION['wizard_create'] = $uuid;
|
||||
} else {
|
||||
$articles->autosave($uuid, $title, $content, $postSlug);
|
||||
}
|
||||
header('Location: /new/' . rawurlencode($uuid) . '/2');
|
||||
exit;
|
||||
}
|
||||
$title = $draft['title'] ?? ($_POST['title'] ?? '');
|
||||
$title = $draft['title'] ?? '';
|
||||
$content = $draft['content'] ?? '';
|
||||
$postSlug = $draft['slug'] ?? '';
|
||||
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
|
||||
@@ -865,15 +872,11 @@ switch ($action) {
|
||||
|
||||
case 1:
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$content = $_POST['content'] ?? '';
|
||||
if ($title === '') {
|
||||
$errors[] = 'Le titre est obligatoire.';
|
||||
} else {
|
||||
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'])], $content);
|
||||
header('Location: /edit/' . rawurlencode($uuid) . '/2');
|
||||
exit;
|
||||
}
|
||||
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||||
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
|
||||
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'] ?? '')], $content);
|
||||
header('Location: /edit/' . rawurlencode($uuid) . '/2');
|
||||
exit;
|
||||
}
|
||||
$title = $draft['title'];
|
||||
$content = $draft['content'];
|
||||
@@ -1465,15 +1468,13 @@ switch ($action) {
|
||||
echo json_encode(['ok' => false]);
|
||||
exit;
|
||||
}
|
||||
$asTitle = trim($_POST['title'] ?? '');
|
||||
$asContent = $_POST['content'] ?? '';
|
||||
$asContent = str_replace("\r\n", "\n", $_POST['content'] ?? '');
|
||||
$asSlug = trim($_POST['slug'] ?? '');
|
||||
if ($asTitle === '') {
|
||||
echo json_encode(['ok' => false]);
|
||||
exit;
|
||||
}
|
||||
$_asCurrent = $articles->getByUuid($uuid);
|
||||
$asTitle = extractMarkdownTitle($asContent) ?: ($_asCurrent['title'] ?? 'Sans titre');
|
||||
$ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug);
|
||||
echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]);
|
||||
$_asSlugFinal = $ok ? ($articles->getByUuid($uuid)['slug'] ?? '') : '';
|
||||
echo json_encode(['ok' => $ok, 'time' => date('H:i:s'), 'title' => $asTitle, 'slug' => $_asSlugFinal]);
|
||||
exit;
|
||||
|
||||
case 'autosave_draft':
|
||||
@@ -1488,14 +1489,12 @@ switch ($action) {
|
||||
echo json_encode(['ok' => false]);
|
||||
exit;
|
||||
}
|
||||
$_adTitle = trim($_POST['title'] ?? '');
|
||||
$_adContent = $_POST['content'] ?? null;
|
||||
if ($_adTitle === '') {
|
||||
echo json_encode(['ok' => false]);
|
||||
exit;
|
||||
}
|
||||
$_adContent = isset($_POST['content']) ? str_replace("\r\n", "\n", $_POST['content']) : null;
|
||||
$_adTitle = $_adContent !== null
|
||||
? (extractMarkdownTitle($_adContent) ?: ($_adArticle['title'] ?? 'Sans titre'))
|
||||
: ($_adArticle['title'] ?? 'Sans titre');
|
||||
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent);
|
||||
echo json_encode(['ok' => true, 'time' => date('H:i:s')]);
|
||||
echo json_encode(['ok' => true, 'time' => date('H:i:s'), 'title' => $_adTitle]);
|
||||
exit;
|
||||
|
||||
case 'edit_discard_draft':
|
||||
@@ -2733,6 +2732,42 @@ switch ($action) {
|
||||
header('Location: /admin/users');
|
||||
exit;
|
||||
|
||||
case 'run_content_migrations':
|
||||
requireAuth();
|
||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
$_cmDataDir = BASE_PATH . '/data';
|
||||
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
|
||||
$_cmFlag = $_cmDataDir . '/.maintenance';
|
||||
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
|
||||
$_cmFiles = glob(BASE_PATH . '/scripts/content/migration_*.php') ?: [];
|
||||
sort($_cmFiles);
|
||||
$_cmPending = array_values(array_filter($_cmFiles, fn ($f) => !isset($_cmApplied[basename($f)])));
|
||||
if (empty($_cmPending)) {
|
||||
header('Location: /admin?tab=dashboard¬ice=no_migrations');
|
||||
exit;
|
||||
}
|
||||
file_put_contents($_cmFlag, date('Y-m-d H:i:s'));
|
||||
$_cmErrors = 0;
|
||||
$dataDir = $_cmDataDir;
|
||||
foreach ($_cmPending as $_cmFile) {
|
||||
try {
|
||||
require $_cmFile;
|
||||
$_cmApplied[basename($_cmFile)] = date('Y-m-d H:i:s');
|
||||
file_put_contents($_cmTrack, json_encode($_cmApplied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");
|
||||
} catch (Throwable $_cmEx) {
|
||||
$_cmErrors++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (file_exists($_cmFlag)) {
|
||||
unlink($_cmFlag);
|
||||
}
|
||||
header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
|
||||
exit;
|
||||
|
||||
case 'admin_save_site':
|
||||
requireAuth();
|
||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.2.0
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Migration 001 : préfixe chaque article avec `# Titre` si aucun titre
|
||||
// Markdown de niveau 1 n'est déjà présent dans le contenu.
|
||||
//
|
||||
// Variables disponibles depuis le runner : $dataDir
|
||||
|
||||
/** @var string $dataDir */
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach (glob($dataDir . '/*/meta.json') as $metaPath) {
|
||||
$dir = dirname($metaPath);
|
||||
$mdPath = $dir . '/index.md';
|
||||
|
||||
if (!file_exists($mdPath)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = json_decode((string) file_get_contents($metaPath), true);
|
||||
if (!is_array($meta)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = trim($meta['title'] ?? '');
|
||||
$content = (string) file_get_contents($mdPath);
|
||||
$content = str_replace("\r\n", "\n", $content);
|
||||
|
||||
// Déjà un titre Markdown niveau 1 → on ne touche pas
|
||||
if (preg_match('/^\s*#\s+\S/', $content)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($mdPath, '# ' . $title . "\n\n" . ltrim($content));
|
||||
$updated++;
|
||||
}
|
||||
|
||||
echo " $updated article(s) mis à jour, $skipped ignoré(s)\n";
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Runner de migrations de contenu (fichiers articles).
|
||||
// Analogie avec database/migrate.php, mais pour les fichiers data/.
|
||||
//
|
||||
// Usage : php scripts/migrate_content.php [/chemin/vers/data]
|
||||
//
|
||||
// - Lit data/.content_migrations.json pour savoir ce qui a déjà tourné.
|
||||
// - Active data/.maintenance pendant l'exécution (→ page 503 aux visiteurs).
|
||||
// - Applique les scripts scripts/content/migration_*.php non encore appliqués.
|
||||
|
||||
$baseDir = dirname(__DIR__);
|
||||
$dataDir = $argv[1] ?? ($baseDir . '/data');
|
||||
|
||||
if (!is_dir($dataDir)) {
|
||||
fwrite(STDERR, "Répertoire data introuvable : $dataDir\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$trackFile = $dataDir . '/.content_migrations.json';
|
||||
$maintenanceFlag = $dataDir . '/.maintenance';
|
||||
|
||||
$applied = [];
|
||||
if (file_exists($trackFile)) {
|
||||
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
|
||||
}
|
||||
|
||||
$files = glob(__DIR__ . '/content/migration_*.php') ?: [];
|
||||
sort($files);
|
||||
|
||||
$pending = array_values(array_filter($files, fn ($f) => !isset($applied[basename($f)])));
|
||||
|
||||
if (empty($pending)) {
|
||||
echo " (aucune migration de contenu en attente)\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
file_put_contents($maintenanceFlag, date('Y-m-d H:i:s'));
|
||||
echo "→ Mode maintenance activé\n";
|
||||
|
||||
$count = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($pending as $file) {
|
||||
$name = basename($file);
|
||||
echo " → $name ... ";
|
||||
try {
|
||||
require $file;
|
||||
$applied[$name] = date('Y-m-d H:i:s');
|
||||
file_put_contents(
|
||||
$trackFile,
|
||||
json_encode($applied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
|
||||
);
|
||||
echo "✓\n";
|
||||
$count++;
|
||||
} catch (Throwable $e) {
|
||||
echo "✗ " . $e->getMessage() . "\n";
|
||||
$errors++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists($maintenanceFlag)) {
|
||||
unlink($maintenanceFlag);
|
||||
}
|
||||
echo "→ Mode maintenance désactivé\n";
|
||||
echo "→ $count migration(s) appliquée(s)" . ($errors ? ", $errors erreur(s)" : '') . ".\n";
|
||||
|
||||
exit($errors > 0 ? 1 : 0);
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pousse le code Folio vers git.abonnel.fr/cedricAbonnel/folio
|
||||
# Usage : ./scripts/push.sh "message de commit"
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$SCRIPT_DIR/.."
|
||||
MSG="${1:-}"
|
||||
|
||||
if [[ -z "$MSG" ]]; then
|
||||
echo "Usage: $0 \"message de commit\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
git init -b main
|
||||
git remote add origin https://git.abonnel.fr/cedricAbonnel/folio.git
|
||||
echo "→ Dépôt git initialisé"
|
||||
fi
|
||||
|
||||
# Extraire la version depuis CHANGELOG.md (première entrée ## [X.Y.Z])
|
||||
FOLIO_VERSION=$(grep -m1 '^\#\# \[[0-9]' CHANGELOG.md | sed 's/.*\[\([^]]*\)\].*/\1/')
|
||||
if [[ -z "$FOLIO_VERSION" ]]; then
|
||||
echo "✗ Impossible d'extraire la version depuis CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "$FOLIO_VERSION" > public/version.txt
|
||||
echo "→ Version : $FOLIO_VERSION"
|
||||
|
||||
git add -A
|
||||
git diff --cached --quiet && echo "(rien à committer)" && exit 0
|
||||
git commit -m "$MSG"
|
||||
git push origin main
|
||||
echo "✓ Poussé vers folio"
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Vérifie si une mise à jour de Folio est disponible sur le dépôt Git,
|
||||
* et si des migrations de contenu sont en attente.
|
||||
*
|
||||
* Aucune dépendance externe : utilise file_get_contents() + cache JSON.
|
||||
*/
|
||||
class UpdateChecker
|
||||
{
|
||||
private string $dataDir;
|
||||
private string $baseDir;
|
||||
|
||||
public function __construct(string $dataDir, string $baseDir)
|
||||
{
|
||||
$this->dataDir = $dataDir;
|
||||
$this->baseDir = $baseDir;
|
||||
}
|
||||
|
||||
/** Retourne la liste des alertes à afficher aux administrateurs. */
|
||||
public function adminNotices(): array
|
||||
{
|
||||
$notices = [];
|
||||
|
||||
if ($this->hasPendingContentMigrations()) {
|
||||
$notices[] = [
|
||||
'type' => 'warning',
|
||||
'message' => 'Des migrations de contenu sont en attente.',
|
||||
];
|
||||
}
|
||||
|
||||
$update = $this->checkRemoteVersion();
|
||||
if ($update !== null) {
|
||||
$notices[] = [
|
||||
'type' => 'info',
|
||||
'message' => 'Une nouvelle version de Folio est disponible : <strong>v' . htmlspecialchars($update) . '</strong>.',
|
||||
];
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
// ─── Migrations de contenu en attente ────────────────────────────────────
|
||||
|
||||
private function hasPendingContentMigrations(): bool
|
||||
{
|
||||
$trackFile = $this->dataDir . '/.content_migrations.json';
|
||||
$applied = [];
|
||||
if (file_exists($trackFile)) {
|
||||
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
|
||||
}
|
||||
foreach (glob($this->baseDir . '/scripts/content/migration_*.php') ?: [] as $f) {
|
||||
if (!isset($applied[basename($f)])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Vérification version distante (Gitea) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Retourne le numéro de la version disponible si elle est supérieure
|
||||
* à la version déployée, null sinon.
|
||||
*/
|
||||
private function checkRemoteVersion(): ?string
|
||||
{
|
||||
$repoUrl = rtrim((string) ($_ENV['FOLIO_REPO_URL'] ?? getenv('FOLIO_REPO_URL') ?: ''), '/');
|
||||
if ($repoUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deployedFile = $this->baseDir . '/public/version.txt';
|
||||
if (!file_exists($deployedFile)) {
|
||||
return null;
|
||||
}
|
||||
$deployedVer = trim((string) file_get_contents($deployedFile));
|
||||
if ($deployedVer === '' || !preg_match('/^\d+\.\d+\.\d+/', $deployedVer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$remoteVer = $this->fetchRemoteVersion($repoUrl);
|
||||
if ($remoteVer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère `public/version.txt` depuis le dépôt Gitea (branche main).
|
||||
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
|
||||
*/
|
||||
private function fetchRemoteVersion(string $repoUrl): ?string
|
||||
{
|
||||
$cacheFile = $this->dataDir . '/.version_check_cache.json';
|
||||
$ttl = 3600;
|
||||
|
||||
if (file_exists($cacheFile)) {
|
||||
$cache = json_decode((string) file_get_contents($cacheFile), true) ?? [];
|
||||
if (isset($cache['fetched_at'], $cache['version'])
|
||||
&& (time() - (int) $cache['fetched_at']) < $ttl
|
||||
) {
|
||||
return (string) $cache['version'];
|
||||
}
|
||||
}
|
||||
|
||||
// URL du fichier brut : {repo}/raw/branch/main/public/version.txt
|
||||
$rawUrl = $repoUrl . '/raw/branch/main/public/version.txt';
|
||||
|
||||
$token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: '');
|
||||
$opts = [
|
||||
'http' => [
|
||||
'timeout' => 5,
|
||||
'header' => $token !== '' ? "Authorization: token $token" : '',
|
||||
],
|
||||
];
|
||||
|
||||
$body = @file_get_contents($rawUrl, false, stream_context_create($opts));
|
||||
if ($body === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = trim($body);
|
||||
if (!preg_match('/^\d+\.\d+\.\d+/', $version)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$cacheFile,
|
||||
json_encode(['fetched_at' => time(), 'version' => $version]) . "\n"
|
||||
);
|
||||
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
+22
-6
@@ -19,19 +19,35 @@ function slugify(string $s): string
|
||||
return trim($s, '-');
|
||||
}
|
||||
|
||||
/** Extrait le titre depuis le premier titre Markdown `# ...` du contenu. */
|
||||
function extractMarkdownTitle(string $content): string
|
||||
{
|
||||
foreach (explode("\n", str_replace("\r\n", "\n", $content)) as $line) {
|
||||
if (preg_match('/^#\s+(.+)/', rtrim($line), $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
|
||||
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
|
||||
*/
|
||||
function lineDiff(string $old, string $new): array
|
||||
{
|
||||
$a = explode("\n", $old);
|
||||
$b = explode("\n", $new);
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
$old = str_replace("\r\n", "\n", $old);
|
||||
$new = str_replace("\r\n", "\n", $new);
|
||||
$a = explode("\n", $old);
|
||||
$b = explode("\n", $new);
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
|
||||
if ($n * $m > 300000) {
|
||||
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]];
|
||||
if ($n * $m > 2_000_000) {
|
||||
$diff = [['!', "Diff trop grand ({$n}×{$m} lignes) — affichage simplifié."]];
|
||||
foreach ($a as $line) { $diff[] = ['-', $line]; }
|
||||
foreach ($b as $line) { $diff[] = ['+', $line]; }
|
||||
return $diff;
|
||||
}
|
||||
|
||||
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
|
||||
|
||||
@@ -91,6 +91,55 @@ function adminStatusBadge(array $a, int $now): string
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Version Folio ──────────────────────────────────────────────────────── -->
|
||||
<?php
|
||||
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
|
||||
$_deployedLabel = $_deployedVer !== '' ? date('d/m/Y H:i', strtotime($_deployedVer)) : '—';
|
||||
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
|
||||
$_remoteLabel = '—';
|
||||
foreach ($_notices as $_n) {
|
||||
if ($_n['type'] === 'info' && preg_match('/publiée le ([^)]+)/', $_n['message'], $_m)) {
|
||||
$_remoteLabel = $_m[1];
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold">Moteur Folio</div>
|
||||
<div class="card-body py-2">
|
||||
<table class="table table-sm table-borderless mb-0 small">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap" style="width:160px">Version déployée</th>
|
||||
<td><?= htmlspecialchars($_deployedLabel) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th>
|
||||
<td><?= htmlspecialchars($_remoteLabel) ?><?= $_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel ? ' <span class="badge bg-warning text-dark ms-1">Mise à jour disponible</span>' : '' ?></td>
|
||||
</tr>
|
||||
<?php if (!empty($_notices)): ?>
|
||||
<tr>
|
||||
<th class="text-muted fw-normal ps-0 pe-2 align-top">Actions requises</th>
|
||||
<td class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<?php foreach ($_notices as $_n): ?>
|
||||
<?php if ($_n['type'] === 'warning'): ?>
|
||||
<form method="POST" action="/?action=run_content_migrations">
|
||||
<button type="submit" class="btn btn-warning btn-sm">Mettre à jour le contenu</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (($_GET['notice'] ?? '') === 'migrated'): ?>
|
||||
<tr><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Migrations appliquées avec succès.</div></td></tr>
|
||||
<?php elseif (($_GET['notice'] ?? '') === 'migration_error'): ?>
|
||||
<tr><td colspan="2"><div class="alert alert-danger py-1 mb-0 small">Une erreur est survenue pendant la migration.</div></td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Activité récente</h5>
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
|
||||
@@ -106,6 +106,25 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<?php if (function_exists('isAdmin') && isAdmin() && isset($_updateChecker)):
|
||||
$_adminNotices = $_updateChecker->adminNotices();
|
||||
foreach ($_adminNotices as $_notice):
|
||||
$_isWarning = $_notice['type'] === 'warning'; ?>
|
||||
<div class="alert alert-<?= $_isWarning ? 'warning' : 'info' ?> alert-dismissible rounded-0 border-0 border-bottom py-2 mb-0 small" role="alert">
|
||||
<div class="container-fluid d-flex align-items-center gap-2 flex-wrap">
|
||||
<span><?= $_notice['message'] ?></span>
|
||||
<?php if ($_isWarning): ?>
|
||||
<form method="POST" action="/?action=run_content_migrations" class="d-inline">
|
||||
<button type="submit" class="btn btn-warning btn-sm py-0">Mettre à jour</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<a href="/admin?tab=dashboard" class="btn btn-outline-primary btn-sm py-0">Voir dans l'admin</a>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Fermer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; endif; ?>
|
||||
|
||||
<main class="<?= htmlspecialchars($mainClass ?? 'container') ?>" role="main">
|
||||
<?= $content ?>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mise à jour en cours</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8f9fa;color:#212529}
|
||||
.box{text-align:center;padding:2.5rem 2rem;max-width:420px}
|
||||
.icon{font-size:2.5rem;margin-bottom:1rem}
|
||||
h1{font-size:1.4rem;font-weight:600;margin-bottom:.6rem}
|
||||
p{color:#6c757d;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="icon">⚙️</div>
|
||||
<h1>Mise à jour en cours</h1>
|
||||
<p>Le site est temporairement indisponible pendant une mise à jour.<br>Merci de réessayer dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,7 +19,8 @@ $_hasUuid = $_wizUuid !== '';
|
||||
<!-- En-tête avec boutons ────────────────────────────────────────────────── -->
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="h4 mb-0"><?= $mode === 'create' ? 'Nouvel article' : htmlspecialchars('Modifier — ' . ($article['title'] ?? '')) ?></h1>
|
||||
<h1 class="h4 mb-0" id="wz-page-title"
|
||||
data-prefix="<?= $mode === 'edit' ? 'Modifier — ' : '' ?>"><?= $mode === 'create' ? 'Nouvel article' : htmlspecialchars('Modifier — ' . ($article['title'] ?? '')) ?></h1>
|
||||
<?php if ($_hasUuid): ?>
|
||||
<span id="autosave-indicator" class="text-muted small"></span>
|
||||
<?php endif; ?>
|
||||
@@ -39,14 +40,6 @@ $_hasUuid = $_wizUuid !== '';
|
||||
<div class="row g-3 align-items-start">
|
||||
<div class="col-lg-9">
|
||||
|
||||
<!-- Titre ─────────────────────────────────────────────────────────────── -->
|
||||
<div class="mb-3">
|
||||
<label for="wz-title" class="form-label fw-semibold">Titre</label>
|
||||
<input type="text" class="form-control form-control-lg" id="wz-title" name="title" required
|
||||
value="<?= htmlspecialchars($title ?? '') ?>"
|
||||
placeholder="Titre de l'article…">
|
||||
</div>
|
||||
|
||||
<!-- Contenu ──────────────────────────────────────────────────────────── -->
|
||||
<div class="mb-3">
|
||||
<label for="wz-content" class="form-label fw-semibold">Contenu <small class="text-muted fw-normal">(Markdown)</small></label>
|
||||
|
||||
Reference in New Issue
Block a user