Simplification
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,7 +3,8 @@
|
|||||||
/logs/*
|
/logs/*
|
||||||
!/logs/.gitkeep
|
!/logs/.gitkeep
|
||||||
/vendor/
|
/vendor/
|
||||||
/tmp/
|
/tmp/*
|
||||||
|
!/tmp/.gitkeep
|
||||||
/public/uploads/media/*
|
/public/uploads/media/*
|
||||||
!/public/uploads/media/.gitkeep
|
!/public/uploads/media/.gitkeep
|
||||||
/config.local.ini
|
/config.local.ini
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Exemple de configuration Caddy en reverse proxy vers l'application.
|
# Exemple de reverse proxy Caddy.
|
||||||
# Copier ce fichier vers Caddyfile et adapter le domaine / la cible.
|
# Copier vers Caddyfile et adapter le domaine et la cible.
|
||||||
# TLS, compression et en-têtes de sécurité restent gérés ici, pas dans l'app PHP.
|
# TLS et en-têtes HTTP se gèrent ici.
|
||||||
|
|
||||||
blog.example.com {
|
blog.example.com {
|
||||||
# ── En-têtes de sécurité (toutes les réponses) ───────────────────
|
# En-têtes de sécurité
|
||||||
|
|
||||||
header {
|
header {
|
||||||
Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'"
|
Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; img-src 'self' data:; style-src 'self'; script-src 'self'"
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -8,9 +8,12 @@ RUN apt-get update \
|
|||||||
libonig-dev \
|
libonig-dev \
|
||||||
libicu-dev \
|
libicu-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libpng-dev \
|
||||||
unzip \
|
unzip \
|
||||||
git \
|
git \
|
||||||
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
|
&& docker-php-ext-configure gd --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
@@ -29,7 +32,10 @@ RUN apt-get update \
|
|||||||
libonig-dev \
|
libonig-dev \
|
||||||
libicu-dev \
|
libicu-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom \
|
libjpeg62-turbo-dev \
|
||||||
|
libpng-dev \
|
||||||
|
&& docker-php-ext-configure gd --with-jpeg \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite mbstring opcache intl dom gd \
|
||||||
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
||||||
&& a2enconf servername \
|
&& a2enconf servername \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
206
README.md
206
README.md
@@ -1,190 +1,74 @@
|
|||||||
# F3 Simple Blog
|
# F3 Simple Blog
|
||||||
|
|
||||||
Blog simple construit avec Fat-Free Framework et SQLite.
|
Blog minimal en **Fat-Free Framework** avec **SQLite**.
|
||||||
|
|
||||||
Le projet vise un blog léger, lisible et facile à déployer, avec un petit back-office d’administration, une médiathèque locale et un rendu Markdown sécurisé.
|
- rendu côté serveur
|
||||||
|
- routes dans `config.ini`
|
||||||
|
- modèles `DB\SQL\Mapper`
|
||||||
|
- édition en Markdown
|
||||||
|
- médiathèque paginée séparée de l’éditeur
|
||||||
|
- nettoyage des fichiers orphelins via script CLI
|
||||||
|
|
||||||
## Fonctionnalités
|
## Prérequis
|
||||||
|
|
||||||
- listing public des articles avec pagination ;
|
- PHP 8.3+
|
||||||
- page article ;
|
- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom`, `gd`
|
||||||
- authentification admin ;
|
- Composer
|
||||||
- création, modification et suppression d’articles ;
|
|
||||||
- médiathèque locale avec upload, texte alternatif, copie de la syntaxe Markdown et suppression ;
|
## Installation
|
||||||
- vignette de carte dérivée de la première image du contenu ;
|
|
||||||
- rendu Markdown avec images locales `media:...` ;
|
```bash
|
||||||
- stockage des images en **JPG/PNG bruts** sans transformation.
|
composer install
|
||||||
|
php scripts/install.php
|
||||||
|
php scripts/create-admin.php admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancer ensuite un serveur local :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8000 -t public
|
||||||
|
```
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
project/
|
app/ contrôleurs, modèles, vues et service Markdown
|
||||||
├── app/
|
public/ assets et fichiers médias
|
||||||
│ ├── bootstrap.php
|
scripts/ installation, création d’admin, nettoyage des orphelins
|
||||||
│ ├── config.ini
|
|
||||||
│ ├── helpers.php
|
|
||||||
│ ├── Controllers/
|
|
||||||
│ ├── Models/
|
|
||||||
│ ├── Services/
|
|
||||||
│ └── Views/
|
|
||||||
├── db/
|
|
||||||
├── logs/
|
|
||||||
├── public/
|
|
||||||
│ ├── assets/
|
|
||||||
│ └── uploads/media/
|
|
||||||
├── docker/
|
|
||||||
├── scripts/
|
|
||||||
│ ├── bootstrap.php
|
|
||||||
│ ├── install.php
|
|
||||||
│ └── create-admin.php
|
|
||||||
└── tmp/uploads/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Flux éditorial
|
||||||
|
|
||||||
Le backend s’appuie sur un petit noyau :
|
- Les articles sont rédigés en Markdown.
|
||||||
|
- Les images sont gérées dans la médiathèque.
|
||||||
|
- La médiathèque fournit un bouton **Copier le Markdown**.
|
||||||
|
- L’éditeur reste simple : textarea, petite toolbar Markdown et lien vers la médiathèque.
|
||||||
|
|
||||||
- `SiteController` : pages publiques ;
|
## Médias
|
||||||
- `AuthController` : connexion / déconnexion ;
|
|
||||||
- `AdminController` : back-office articles et médiathèque ;
|
|
||||||
- `Controller` : rendu, session courante, flash, CSRF ;
|
|
||||||
- `Post`, `Media`, `User` : modèles `DB\SQL\Mapper` ;
|
|
||||||
- `MarkdownService` : compilation et sanitation du contenu Markdown.
|
|
||||||
|
|
||||||
## Intégration F3
|
La table `media` est la référence côté application.
|
||||||
|
|
||||||
Le projet utilise directement les briques natives du framework :
|
- Supprimer un média retire la ligne SQL.
|
||||||
|
- Les fichiers orphelins éventuels sont nettoyés via un script CLI.
|
||||||
|
|
||||||
- routes et aliases dans `app/config.ini` ;
|
Lister les orphelins :
|
||||||
- `DB\SQL\Mapper` pour les tables principales ;
|
|
||||||
- `Auth` pour la connexion ;
|
|
||||||
- `Session` pour la session ;
|
|
||||||
- `Template` pour le rendu ;
|
|
||||||
- `Web::receive()` pour la réception des uploads ;
|
|
||||||
- `Markdown` pour le parsing Markdown ;
|
|
||||||
- `Web::slug()` pour les slugs.
|
|
||||||
|
|
||||||
## Contenu et médiathèque
|
|
||||||
|
|
||||||
Les articles contiennent leur texte en Markdown. Les images sont insérées dans le corps du contenu, et la première image rendue dans `body_html` sert de vignette dans les cartes d’article.
|
|
||||||
|
|
||||||
Les images du contenu utilisent la syntaxe :
|
|
||||||
|
|
||||||
```md
|
|
||||||

|
|
||||||
```
|
|
||||||
|
|
||||||
La médiathèque :
|
|
||||||
|
|
||||||
- accepte uniquement **JPG** et **PNG** ;
|
|
||||||
- vérifie que le fichier reçu est bien une image ;
|
|
||||||
- conserve le fichier tel quel, sans réencodage ;
|
|
||||||
- stocke en base le nom du fichier, le texte alternatif, la largeur, la hauteur et la date de création.
|
|
||||||
|
|
||||||
## Contrat Markdown
|
|
||||||
|
|
||||||
Le rendu Markdown suit les règles suivantes :
|
|
||||||
|
|
||||||
- le HTML brut saisi par l’auteur n’est pas rendu ;
|
|
||||||
- les liens sont filtrés avant rendu ;
|
|
||||||
- seules les images locales en `media:...` sont rendues ;
|
|
||||||
- les images externes ne sont pas affichées ;
|
|
||||||
- avec le parseur Markdown de F3, il faut laisser une ligne vide entre deux blocs (titre, liste, citation, image, code) pour un rendu fiable.
|
|
||||||
|
|
||||||
## Sécurité
|
|
||||||
|
|
||||||
Le projet inclut :
|
|
||||||
|
|
||||||
- session d’administration ;
|
|
||||||
- jeton CSRF sur les formulaires ;
|
|
||||||
- rotation du jeton CSRF après connexion et déconnexion ;
|
|
||||||
- sanitation du HTML produit à partir du Markdown ;
|
|
||||||
- validation des uploads image ;
|
|
||||||
- cookies `httponly` et `samesite=Lax`.
|
|
||||||
|
|
||||||
## Pré-requis
|
|
||||||
|
|
||||||
### Développement local
|
|
||||||
|
|
||||||
- PHP 8.3+
|
|
||||||
- Composer
|
|
||||||
- extensions PHP : `pdo_sqlite`, `mbstring`, `intl`, `dom`
|
|
||||||
|
|
||||||
### Déploiement Docker
|
|
||||||
|
|
||||||
- Docker
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
## Démarrage local
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer install
|
php scripts/clean-orphan-media.php list
|
||||||
cp config.local.ini.example config.local.ini
|
|
||||||
php scripts/install.php
|
|
||||||
php -S 127.0.0.1:8080 -t public
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis ouvrir `http://127.0.0.1:8080`.
|
Supprimer les orphelins :
|
||||||
|
|
||||||
Créer un compte admin :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php scripts/create-admin.php admin
|
php scripts/clean-orphan-media.php delete
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Déploiement Docker
|
||||||
|
|
||||||
Le fichier `app/config.ini` contient les valeurs par défaut. Tu peux les surcharger dans `config.local.ini`.
|
Une configuration Docker est fournie :
|
||||||
|
|
||||||
Exemple minimal en production :
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[globals]
|
|
||||||
app.env=prod
|
|
||||||
app.timezone=Europe/Paris
|
|
||||||
```
|
|
||||||
|
|
||||||
Le paramètre `app.env` doit être défini à `prod` sur un déploiement réel.
|
|
||||||
|
|
||||||
## Déploiement Docker derrière Caddy
|
|
||||||
|
|
||||||
Déploiement Docker recommandé :
|
|
||||||
|
|
||||||
- Apache sert `public/` dans le conteneur ;
|
|
||||||
- `compose.yaml` expose l’application sur `127.0.0.1:8888` par défaut ;
|
|
||||||
- Caddy termine TLS et reverse-proxy vers cette cible ;
|
|
||||||
- SQLite, les logs et les uploads sont montés sur des volumes persistants.
|
|
||||||
|
|
||||||
Exemple :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.local.ini.example config.local.ini
|
docker compose up --build
|
||||||
# édite config.local.ini : app.env=prod
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Créer un compte admin dans le conteneur :
|
L’application écoute sur `127.0.0.1:8888` par défaut.
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec app php scripts/create-admin.php admin
|
|
||||||
```
|
|
||||||
|
|
||||||
La commande demande ensuite le mot de passe du compte.
|
|
||||||
|
|
||||||
Le `Caddyfile.example` fournit une base de reverse proxy. En production, il faut exposer publiquement **Caddy uniquement**. L’application Apache/PHP ne doit pas être accessible directement depuis Internet.
|
|
||||||
|
|
||||||
## Sauvegarde
|
|
||||||
|
|
||||||
Pour sauvegarder le blog, il faut au minimum conserver :
|
|
||||||
|
|
||||||
- `db/app.sqlite`
|
|
||||||
- `public/uploads/media/`
|
|
||||||
|
|
||||||
Les logs peuvent aussi être conservés si tu veux garder l’historique d’erreurs.
|
|
||||||
|
|
||||||
## Limites assumées
|
|
||||||
|
|
||||||
- base SQLite ;
|
|
||||||
- instance applicative unique ;
|
|
||||||
- pas de système de migrations automatique ;
|
|
||||||
- en cas de changement de schéma, une migration manuelle ou une base recréée sera nécessaire.
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class AdminController extends Controller
|
class AdminController extends Controller
|
||||||
{
|
{
|
||||||
private const MEDIA_PICKER_LIMIT = 60;
|
|
||||||
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
public function beforeRoute(): void
|
public function beforeRoute(): void
|
||||||
@@ -28,52 +27,52 @@ class AdminController extends Controller
|
|||||||
|
|
||||||
public function create(): void
|
public function create(): void
|
||||||
{
|
{
|
||||||
$this->postForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
|
$this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), Post::blank());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(): void
|
public function store(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
$input = $this->postInput();
|
$input = $this->readPostInput();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Post())->savePost($input);
|
(new Post())->savePost($input);
|
||||||
$this->flash('success', 'Article créé.');
|
$this->flash('success', 'Article créé.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->postForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
$this->renderPostForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(): void
|
public function edit(): void
|
||||||
{
|
{
|
||||||
$post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id'));
|
$post = (new Post())->findForForm((int) $this->f3->get('PARAMS.id'));
|
||||||
if (!$post) {
|
if ($post === null) {
|
||||||
$this->f3->error(404);
|
$this->f3->error(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
$this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(): void
|
public function update(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
$id = (int) $this->f3->get('PARAMS.id');
|
$id = (int) $this->f3->get('PARAMS.id');
|
||||||
$input = $this->postInput() + ['id' => $id];
|
$input = $this->readPostInput() + ['id' => $id];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Post())->savePost($input, $id);
|
(new Post())->savePost($input, $id);
|
||||||
$this->flash('success', 'Article mis à jour.');
|
$this->flash('success', 'Article mis à jour.');
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->postForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
$this->renderPostForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(): void
|
public function delete(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
|
(new Post())->deleteById((int) $this->f3->get('PARAMS.id'));
|
||||||
@@ -95,25 +94,27 @@ class AdminController extends Controller
|
|||||||
'items' => $result['items'],
|
'items' => $result['items'],
|
||||||
'pagination' => $result['pagination'],
|
'pagination' => $result['pagination'],
|
||||||
'paginationAlias' => 'media_index',
|
'paginationAlias' => 'media_index',
|
||||||
|
'adminMode' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mediaUpload(): void
|
public function mediaUpload(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$original = (string) ($this->f3->get('FILES.image.name') ?: '');
|
$originalName = (string) ($this->f3->get('FILES.image.name') ?: '');
|
||||||
$received = Web::instance()->receive(
|
$received = Web::instance()->receive(
|
||||||
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
fn(array $file): bool => (int) ($file['size'] ?? 0) > 0 && (int) ($file['size'] ?? 0) <= self::UPLOAD_MAX_BYTES,
|
||||||
overwrite: false
|
overwrite: false
|
||||||
);
|
);
|
||||||
|
|
||||||
$path = array_key_first(array_filter($received));
|
$path = array_key_first(array_filter($received));
|
||||||
if (!$path) {
|
if (!$path) {
|
||||||
throw new RuntimeException('Choisis une image valide à envoyer.');
|
throw new RuntimeException('Choisis une image JPG ou PNG valide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
(new Media())->upload($path, $original);
|
(new Media())->upload($path, $originalName);
|
||||||
$this->flash('success', 'Image ajoutée.');
|
$this->flash('success', 'Image ajoutée.');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->flash('error', $e->getMessage());
|
$this->flash('error', $e->getMessage());
|
||||||
@@ -124,11 +125,14 @@ class AdminController extends Controller
|
|||||||
|
|
||||||
public function mediaAlt(): void
|
public function mediaAlt(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new Media())->updateAlt((int) $this->f3->get('PARAMS.id'), $this->f3->clean((string) ($this->f3->get('POST.alt') ?: '')));
|
(new Media())->updateAlt(
|
||||||
$this->flash('success', 'Texte alternatif mis à jour.');
|
(int) $this->f3->get('PARAMS.id'),
|
||||||
|
$this->f3->clean((string) ($this->f3->get('POST.alt') ?: ''))
|
||||||
|
);
|
||||||
|
$this->flash('success', 'Texte alternatif enregistré.');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
$this->flash('error', $e->getMessage());
|
$this->flash('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -138,17 +142,18 @@ class AdminController extends Controller
|
|||||||
|
|
||||||
public function mediaDelete(): void
|
public function mediaDelete(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$media = new Media();
|
$media = new Media();
|
||||||
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
|
$item = $media->findById((int) $this->f3->get('PARAMS.id'));
|
||||||
if (!$item) {
|
if ($item === null) {
|
||||||
throw new RuntimeException('Image introuvable.');
|
throw new RuntimeException('Image introuvable.');
|
||||||
}
|
}
|
||||||
if ((new Post())->usesMedia($item['file_name'])) {
|
if ((new Post())->usesMedia($item['file_name'])) {
|
||||||
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
throw new RuntimeException('Cette image est utilisée dans un article.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$media->deleteById($item['id']);
|
$media->deleteById($item['id']);
|
||||||
$this->flash('success', 'Image supprimée.');
|
$this->flash('success', 'Image supprimée.');
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
@@ -158,27 +163,22 @@ class AdminController extends Controller
|
|||||||
$this->f3->reroute('@media_index');
|
$this->f3->reroute('@media_index');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function postForm(string $title, string $action, array $post, ?string $error = null): void
|
private function renderPostForm(string $title, string $action, array $post, ?string $error = null): void
|
||||||
{
|
{
|
||||||
$media = new Media();
|
$flash = $error ? [['type' => 'error', 'message' => $error]] : [];
|
||||||
$items = $media->recent(self::MEDIA_PICKER_LIMIT);
|
|
||||||
$count = $media->count();
|
|
||||||
|
|
||||||
$this->render('admin/post_form.html', [
|
$this->render('admin/post_form.html', [
|
||||||
'pageTitle' => $title,
|
'pageTitle' => $title,
|
||||||
'formAction' => $action,
|
'formAction' => $action,
|
||||||
'post' => $post,
|
'post' => $post,
|
||||||
'mediaItems' => $items,
|
|
||||||
'mediaCount' => $count,
|
|
||||||
'mediaPickerLimit' => self::MEDIA_PICKER_LIMIT,
|
|
||||||
'mediaPickerTruncated' => $count > count($items),
|
|
||||||
'titleMax' => Post::TITLE_MAX_LENGTH,
|
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||||
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||||
'flash' => $error ? [['type' => 'error', 'message' => $error]] : [],
|
'flash' => $flash,
|
||||||
|
'adminMode' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function postInput(): array
|
private function readPostInput(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
|
'title' => $this->f3->clean((string) ($this->f3->get('POST.title') ?: '')),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class AuthController extends Controller
|
|||||||
{
|
{
|
||||||
public function show(): void
|
public function show(): void
|
||||||
{
|
{
|
||||||
if ($this->user()) {
|
if ($this->currentUser()) {
|
||||||
$this->f3->reroute('@dashboard');
|
$this->f3->reroute('@dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,11 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(): void
|
public function login(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
|
|
||||||
$user = new User();
|
$user = new User();
|
||||||
$auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
|
$auth = new Auth($user, ['id' => 'username', 'pw' => 'password_hash'], 'password_verify');
|
||||||
|
|
||||||
$ok = $auth->login(
|
$ok = $auth->login(
|
||||||
$this->f3->clean((string) ($this->f3->get('POST.username') ?: '')),
|
$this->f3->clean((string) ($this->f3->get('POST.username') ?: '')),
|
||||||
(string) ($this->f3->get('POST.password') ?: '')
|
(string) ($this->f3->get('POST.password') ?: '')
|
||||||
@@ -27,7 +28,7 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
if (!$ok) {
|
if (!$ok) {
|
||||||
usleep(1000000);
|
usleep(1000000);
|
||||||
$this->flash('error', 'Identifiants invalides.');
|
$this->flash('error', 'Identifiants incorrects.');
|
||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -41,11 +42,11 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
$this->checkCsrf();
|
$this->verifyCsrf();
|
||||||
$this->f3->clear('SESSION.user_id');
|
$this->f3->clear('SESSION.user_id');
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$this->rotateCsrf();
|
$this->rotateCsrf();
|
||||||
$this->flash('success', 'Déconnexion effectuée.');
|
$this->flash('success', 'Déconnexion réussie.');
|
||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,77 +13,69 @@ abstract class Controller
|
|||||||
|
|
||||||
protected function render(string $view, array $data = [], int $ttl = 0): void
|
protected function render(string $view, array $data = [], int $ttl = 0): void
|
||||||
{
|
{
|
||||||
$this->ensureCsrf();
|
$user = $this->currentUser();
|
||||||
$user = $this->user();
|
|
||||||
$flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash();
|
$flash = array_key_exists('flash', $data) ? $data['flash'] : $this->pullFlash();
|
||||||
|
|
||||||
$this->f3->expire($user ? 0 : $ttl);
|
$this->f3->expire($user ? 0 : $ttl);
|
||||||
$this->f3->mset($data + [
|
$this->f3->mset($data + [
|
||||||
'view' => $view,
|
'CSRF' => $this->csrfToken(),
|
||||||
'flash' => is_array($flash) ? $flash : [],
|
|
||||||
'currentUser' => $user,
|
'currentUser' => $user,
|
||||||
|
'flash' => is_array($flash) ? $flash : [],
|
||||||
|
'view' => $view,
|
||||||
'adminMode' => false,
|
'adminMode' => false,
|
||||||
'metaDescription' => null,
|
'metaDescription' => null,
|
||||||
'CSRF_TOKEN' => (string) $this->f3->get('SESSION.csrf_token'),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
echo Template::instance()->render('layout.html');
|
echo Template::instance()->render('layout.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function user(): ?array
|
protected function currentUser(): ?array
|
||||||
{
|
{
|
||||||
if (!$this->f3->exists('ctx.user_loaded')) {
|
|
||||||
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
|
$id = (int) ($this->f3->get('SESSION.user_id') ?: 0);
|
||||||
$this->f3->set('currentUser', $id > 0 ? (new User())->findPublic($id) : null);
|
return $id > 0 ? (new User())->findPublic($id) : null;
|
||||||
$this->f3->set('ctx.user_loaded', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->f3->get('currentUser');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function requireAuth(): void
|
protected function requireAuth(): void
|
||||||
{
|
{
|
||||||
if ($this->user()) {
|
if ($this->currentUser()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->flash('error', 'Connecte-toi pour continuer.');
|
$this->flash('error', 'Connecte-toi pour accéder à cette page.');
|
||||||
$this->f3->reroute('@login');
|
$this->f3->reroute('@login');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function checkCsrf(): void
|
protected function csrfToken(): string
|
||||||
{
|
{
|
||||||
$sent = trim((string) ($this->f3->get('POST.csrf_token') ?: ''));
|
$token = trim((string) ($this->f3->get('SESSION.csrf') ?: ''));
|
||||||
$expected = trim((string) ($this->f3->get('SESSION.csrf_token') ?: ''));
|
if ($token === '') {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->f3->set('SESSION.csrf', $token);
|
||||||
|
}
|
||||||
|
|
||||||
if ($sent !== '' && $expected !== '' && hash_equals($expected, $sent)) {
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function verifyCsrf(): void
|
||||||
|
{
|
||||||
|
$sent = trim((string) ($this->f3->get('POST.csrf') ?: ''));
|
||||||
|
if ($sent !== '' && hash_equals($this->csrfToken(), $sent)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->f3->error(400);
|
$this->f3->error(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function rotateCsrf(): void
|
||||||
|
{
|
||||||
|
$this->f3->set('SESSION.csrf', bin2hex(random_bytes(32)));
|
||||||
|
}
|
||||||
|
|
||||||
protected function flash(string $type, string $message): void
|
protected function flash(string $type, string $message): void
|
||||||
{
|
{
|
||||||
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
|
$this->f3->push('SESSION.flash', ['type' => $type, 'message' => $message]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function rotateCsrf(): void
|
|
||||||
{
|
|
||||||
$this->f3->clear('SESSION.csrf_token');
|
|
||||||
$this->ensureCsrf();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureCsrf(): void
|
|
||||||
{
|
|
||||||
if ($this->f3->exists('SESSION.csrf_token')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seed = trim((string) ($this->f3->get('CSRF') ?: ''));
|
|
||||||
$this->f3->set('SESSION.csrf_token', $seed !== '' ? $seed : bin2hex(random_bytes(16)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function pullFlash(): array
|
private function pullFlash(): array
|
||||||
{
|
{
|
||||||
return $this->f3->pull('SESSION.flash') ?: [];
|
return $this->f3->pull('SESSION.flash') ?: [];
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class SiteController extends Controller
|
|||||||
public function show(): void
|
public function show(): void
|
||||||
{
|
{
|
||||||
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
$post = (new Post())->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
||||||
if (!$post) {
|
if ($post === null) {
|
||||||
$this->f3->error(404);
|
$this->f3->error(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ class Media extends DB\SQL\Mapper
|
|||||||
public function page(int $page, int $perPage): array
|
public function page(int $page, int $perPage): array
|
||||||
{
|
{
|
||||||
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
|
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
|
||||||
|
$items = array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'items' => array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []),
|
'items' => $items,
|
||||||
'pagination' => [
|
'pagination' => [
|
||||||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||||
'pages' => max(1, (int) ($result['count'] ?: 1)),
|
'pages' => max(1, (int) ($result['count'] ?: 1)),
|
||||||
@@ -39,14 +40,6 @@ class Media extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function recent(int $limit): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
fn(self $row): array => $this->decorate($row->cast()),
|
|
||||||
$this->find(null, ['order' => 'created_at DESC, id DESC', 'limit' => $limit]) ?: []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findById(int $id): ?array
|
public function findById(int $id): ?array
|
||||||
{
|
{
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
@@ -63,18 +56,22 @@ class Media extends DB\SQL\Mapper
|
|||||||
return $this->dry() ? null : $this->decorate($this->cast());
|
return $this->dry() ? null : $this->decorate($this->cast());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function upload(string $path, string $originalName = ''): int
|
public function upload(string $temporaryPath, string $originalName = ''): int
|
||||||
{
|
{
|
||||||
if (!is_file($path)) {
|
$f3 = Base::instance();
|
||||||
|
|
||||||
|
if (!is_file($temporaryPath)) {
|
||||||
throw new RuntimeException('Fichier image introuvable.');
|
throw new RuntimeException('Fichier image introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$info = @getimagesize($path);
|
$binary = $f3->read($temporaryPath);
|
||||||
if (!is_array($info)) {
|
$image = new Image();
|
||||||
@unlink($path);
|
if ($binary === '' || $image->load($binary) === false) {
|
||||||
|
@unlink($temporaryPath);
|
||||||
throw new RuntimeException('Fichier image invalide.');
|
throw new RuntimeException('Fichier image invalide.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$info = @getimagesizefromstring($binary);
|
||||||
$mime = strtolower((string) ($info['mime'] ?? ''));
|
$mime = strtolower((string) ($info['mime'] ?? ''));
|
||||||
$extension = match ($mime) {
|
$extension = match ($mime) {
|
||||||
'image/jpeg' => 'jpg',
|
'image/jpeg' => 'jpg',
|
||||||
@@ -83,28 +80,39 @@ class Media extends DB\SQL\Mapper
|
|||||||
};
|
};
|
||||||
|
|
||||||
if ($extension === null) {
|
if ($extension === null) {
|
||||||
@unlink($path);
|
@unlink($temporaryPath);
|
||||||
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
|
throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$encoded = match ($extension) {
|
||||||
|
'jpg' => $image->dump('jpeg', 90),
|
||||||
|
'png' => $image->dump('png'),
|
||||||
|
};
|
||||||
|
|
||||||
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
$fileName = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||||
$target = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
|
$target = $this->storagePath($fileName);
|
||||||
|
|
||||||
if (!@rename($path, $target)) {
|
try {
|
||||||
if (!@copy($path, $target)) {
|
$f3->write($target, $encoded);
|
||||||
@unlink($path);
|
} catch (Throwable $e) {
|
||||||
throw new RuntimeException('Impossible d’enregistrer cette image.');
|
@unlink($temporaryPath);
|
||||||
}
|
throw new RuntimeException('Impossible d’enregistrer cette image.', 0, $e);
|
||||||
@unlink($path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@unlink($temporaryPath);
|
||||||
|
|
||||||
|
try {
|
||||||
$this->reset();
|
$this->reset();
|
||||||
$this->file_name = $fileName;
|
$this->file_name = $fileName;
|
||||||
$this->alt = $this->altFromName($originalName);
|
$this->alt = $this->guessAlt($originalName);
|
||||||
$this->width = (int) $info[0];
|
$this->width = $image->width();
|
||||||
$this->height = (int) $info[1];
|
$this->height = $image->height();
|
||||||
$this->created_at = app_now();
|
$this->created_at = app_now();
|
||||||
$this->save();
|
$this->save();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
@unlink($target);
|
||||||
|
throw new RuntimeException('Impossible de finaliser cette image.', 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
return (int) $this->id;
|
return (int) $this->id;
|
||||||
}
|
}
|
||||||
@@ -127,34 +135,41 @@ class Media extends DB\SQL\Mapper
|
|||||||
throw new RuntimeException('Image introuvable.');
|
throw new RuntimeException('Image introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $this->file_name;
|
try {
|
||||||
$this->erase();
|
$this->erase();
|
||||||
if (is_file($path)) {
|
} catch (Throwable $e) {
|
||||||
@unlink($path);
|
throw new RuntimeException('Impossible de supprimer cette image.', 0, $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function decorate(array $row): array
|
private function decorate(array $row): array
|
||||||
{
|
{
|
||||||
$file = (string) $row['file_name'];
|
$fileName = (string) $row['file_name'];
|
||||||
$alt = (string) $row['alt'];
|
$alt = (string) $row['alt'];
|
||||||
|
$base = rtrim((string) Base::instance()->get('BASE'), '/');
|
||||||
|
$mediaBase = rtrim((string) Base::instance()->get('paths.media_base'), '/');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row['id'],
|
'id' => (int) $row['id'],
|
||||||
'file_name' => $file,
|
'file_name' => $fileName,
|
||||||
'alt' => $alt,
|
'alt' => $alt,
|
||||||
'width' => (int) $row['width'],
|
'width' => (int) $row['width'],
|
||||||
'height' => (int) $row['height'],
|
'height' => (int) $row['height'],
|
||||||
'created_at' => (string) $row['created_at'],
|
'created_at' => (string) $row['created_at'],
|
||||||
'url' => rtrim((string) Base::instance()->get('BASE'), '/') . rtrim((string) Base::instance()->get('paths.media_base'), '/') . '/' . rawurlencode($file),
|
'url' => $base . $mediaBase . '/' . rawurlencode($fileName),
|
||||||
'markdown' => '',
|
'markdown' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function altFromName(string $name): string
|
private function storagePath(string $fileName): string
|
||||||
{
|
{
|
||||||
$name = trim(pathinfo($name, PATHINFO_FILENAME));
|
return rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName;
|
||||||
$name = preg_replace('/[-_]+/', ' ', $name) ?: '';
|
}
|
||||||
return trim($name);
|
|
||||||
|
private function guessAlt(string $originalName): string
|
||||||
|
{
|
||||||
|
$label = trim(pathinfo($originalName, PATHINFO_FILENAME));
|
||||||
|
$label = preg_replace('/[-_]+/', ' ', $label) ?: '';
|
||||||
|
return trim($label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,13 +62,16 @@ class Post extends DB\SQL\Mapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$row = $this->cast();
|
$row = $this->cast();
|
||||||
$post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
|
|
||||||
|
|
||||||
return $post;
|
return $this->summary($row) + ['body_html' => (string) $row['body_html']];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findForForm(int $id): ?array
|
public function findForForm(int $id): ?array
|
||||||
{
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
if ($this->dry()) {
|
if ($this->dry()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -84,12 +87,12 @@ class Post extends DB\SQL\Mapper
|
|||||||
|
|
||||||
public function savePost(array $input, ?int $id = null): int
|
public function savePost(array $input, ?int $id = null): int
|
||||||
{
|
{
|
||||||
$payload = $this->payload($input);
|
$payload = $this->normalizePayload($input);
|
||||||
$now = app_now();
|
$now = app_now();
|
||||||
|
|
||||||
if ($id === null) {
|
if ($id === null) {
|
||||||
$this->reset();
|
$this->reset();
|
||||||
$payload['slug'] = $this->uniqueSlug($payload['title']);
|
$payload['slug'] = $this->nextSlug($payload['title']);
|
||||||
$payload['created_at'] = $now;
|
$payload['created_at'] = $now;
|
||||||
} else {
|
} else {
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
@@ -120,11 +123,11 @@ class Post extends DB\SQL\Mapper
|
|||||||
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
|
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function payload(array $input): array
|
private function normalizePayload(array $input): array
|
||||||
{
|
{
|
||||||
$title = trim((string) ($input['title'] ?? ''));
|
$title = trim((string) ($input['title'] ?? ''));
|
||||||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||||
$body = trim((string) ($input['body_markdown'] ?? ''));
|
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
|
||||||
|
|
||||||
if ($title === '') {
|
if ($title === '') {
|
||||||
throw new RuntimeException('Ajoute un titre.');
|
throw new RuntimeException('Ajoute un titre.');
|
||||||
@@ -142,20 +145,20 @@ class Post extends DB\SQL\Mapper
|
|||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'excerpt' => $excerpt,
|
'excerpt' => $excerpt,
|
||||||
'body_markdown' => $body,
|
'body_markdown' => $bodyMarkdown,
|
||||||
'body_html' => MarkdownService::instance()->compile($body, new Media()),
|
'body_html' => MarkdownService::instance()->compile($bodyMarkdown, new Media()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function uniqueSlug(string $title): string
|
private function nextSlug(string $title): string
|
||||||
{
|
{
|
||||||
$base = app_slug($title);
|
$base = app_slug($title);
|
||||||
$slug = $base;
|
$slug = $base;
|
||||||
$n = 2;
|
$suffix = 2;
|
||||||
|
|
||||||
while ($this->count(['slug = ?', $slug]) > 0) {
|
while ($this->count(['slug = ?', $slug]) > 0) {
|
||||||
$slug = $base . '-' . $n;
|
$slug = $base . '-' . $suffix;
|
||||||
$n++;
|
$suffix++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $slug;
|
return $slug;
|
||||||
@@ -163,7 +166,7 @@ class Post extends DB\SQL\Mapper
|
|||||||
|
|
||||||
private function summary(array $row): array
|
private function summary(array $row): array
|
||||||
{
|
{
|
||||||
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
|
$thumbnail = $this->extractThumbnail((string) ($row['body_html'] ?? ''));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row['id'],
|
'id' => (int) $row['id'],
|
||||||
@@ -177,20 +180,14 @@ class Post extends DB\SQL\Mapper
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function firstImage(string $html): array
|
private function extractThumbnail(string $html): array
|
||||||
{
|
{
|
||||||
if ($html === '') {
|
if ($html === '' || !preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
|
||||||
return ['url' => '', 'alt' => ''];
|
return ['url' => '', 'alt' => ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
|
|
||||||
return ['url' => '', 'alt' => ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tag = $match[1];
|
|
||||||
$alt = '';
|
$alt = '';
|
||||||
|
if (preg_match('~alt="([^"]*)"~i', $match[1], $altMatch)) {
|
||||||
if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
|
|
||||||
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class User extends DB\SQL\Mapper
|
|||||||
|
|
||||||
public function findPublic(int $id): ?array
|
public function findPublic(int $id): ?array
|
||||||
{
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$this->load(['id = ?', $id]);
|
$this->load(['id = ?', $id]);
|
||||||
if ($this->dry()) {
|
if ($this->dry()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class MarkdownService extends Prefab
|
class MarkdownService extends Prefab
|
||||||
{
|
{
|
||||||
private const TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
|
private const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'a', 'img', 'hr', 'br'];
|
||||||
private const ATTRS = [
|
private const ALLOWED_ATTRIBUTES = [
|
||||||
'a' => ['href', 'title', 'rel', 'target'],
|
'a' => ['href', 'title', 'rel', 'target'],
|
||||||
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
|
'img' => ['src', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||||
];
|
];
|
||||||
@@ -17,31 +17,30 @@ class MarkdownService extends Prefab
|
|||||||
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$markdown = $this->neutralizeRawHtml($markdown);
|
|
||||||
|
|
||||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||||
$html = '<div id="content">' . Markdown::instance()->convert($markdown) . '</div>';
|
$html = '<div id="content">' . Markdown::instance()->convert($this->escapeRawHtml($markdown)) . '</div>';
|
||||||
|
|
||||||
$previous = libxml_use_internal_errors(true);
|
$previous = libxml_use_internal_errors(true);
|
||||||
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||||
libxml_clear_errors();
|
libxml_clear_errors();
|
||||||
libxml_use_internal_errors($previous);
|
libxml_use_internal_errors($previous);
|
||||||
|
|
||||||
$root = $doc->getElementById('content');
|
$root = $doc->getElementById('content');
|
||||||
if (!$root) {
|
if (!$root instanceof DOMElement) {
|
||||||
return '';
|
throw new RuntimeException('Impossible de générer le contenu HTML.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sanitizeChildren($root, $media);
|
$this->sanitizeNode($root, $media);
|
||||||
|
|
||||||
$out = '';
|
$output = '';
|
||||||
foreach (iterator_to_array($root->childNodes) as $child) {
|
foreach (iterator_to_array($root->childNodes) as $child) {
|
||||||
$out .= $doc->saveHTML($child);
|
$output .= $doc->saveHTML($child);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trim($out);
|
return trim($output);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function neutralizeRawHtml(string $markdown): string
|
private function escapeRawHtml(string $markdown): string
|
||||||
{
|
{
|
||||||
return preg_replace_callback(
|
return preg_replace_callback(
|
||||||
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
|
'~<!--.*?-->|</?[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*)?/?>~s',
|
||||||
@@ -50,7 +49,7 @@ class MarkdownService extends Prefab
|
|||||||
) ?? $markdown;
|
) ?? $markdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sanitizeChildren(DOMNode $parent, Media $media): void
|
private function sanitizeNode(DOMNode $parent, Media $media): void
|
||||||
{
|
{
|
||||||
foreach (iterator_to_array($parent->childNodes) as $child) {
|
foreach (iterator_to_array($parent->childNodes) as $child) {
|
||||||
if (!$child instanceof DOMElement) {
|
if (!$child instanceof DOMElement) {
|
||||||
@@ -58,58 +57,70 @@ class MarkdownService extends Prefab
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tag = strtolower($child->tagName);
|
$tag = strtolower($child->tagName);
|
||||||
if (!in_array($tag, self::TAGS, true)) {
|
if (!in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||||
$this->unwrap($child);
|
$this->unwrap($child);
|
||||||
$this->sanitizeChildren($parent, $media);
|
$this->sanitizeNode($parent, $media);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (iterator_to_array($child->attributes) as $attr) {
|
foreach (iterator_to_array($child->attributes) as $attribute) {
|
||||||
if (!in_array(strtolower($attr->name), self::ATTRS[$tag] ?? [], true)) {
|
if (!in_array(strtolower($attribute->name), self::ALLOWED_ATTRIBUTES[$tag] ?? [], true)) {
|
||||||
$child->removeAttributeNode($attr);
|
$child->removeAttributeNode($attribute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tag === 'a') {
|
if ($tag === 'a') {
|
||||||
$href = trim((string) $child->getAttribute('href'));
|
$this->sanitizeLink($child);
|
||||||
if (!$this->allowedHref($href)) {
|
|
||||||
$this->unwrap($child);
|
|
||||||
$this->sanitizeChildren($parent, $media);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$child->setAttribute('href', $href);
|
|
||||||
$child->setAttribute('rel', 'noopener noreferrer');
|
|
||||||
|
|
||||||
if (preg_match('~^https?://~i', $href)) {
|
|
||||||
$child->setAttribute('target', '_blank');
|
|
||||||
} else {
|
|
||||||
$child->removeAttribute('target');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tag === 'img') {
|
if ($tag === 'img') {
|
||||||
$src = trim((string) $child->getAttribute('src'));
|
$this->sanitizeImage($child, $media);
|
||||||
if (!str_starts_with($src, 'media:')) {
|
if (!$child->parentNode) {
|
||||||
$child->parentNode?->removeChild($child);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sanitizeNode($child, $media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeLink(DOMElement $node): void
|
||||||
|
{
|
||||||
|
$href = trim((string) $node->getAttribute('href'));
|
||||||
|
if (!$this->isAllowedHref($href)) {
|
||||||
|
$this->unwrap($node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->setAttribute('href', $href);
|
||||||
|
$node->setAttribute('rel', 'noopener noreferrer');
|
||||||
|
if (preg_match('~^https?://~i', $href)) {
|
||||||
|
$node->setAttribute('target', '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node->removeAttribute('target');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeImage(DOMElement $node, Media $media): void
|
||||||
|
{
|
||||||
|
$src = trim((string) $node->getAttribute('src'));
|
||||||
|
if (!str_starts_with($src, 'media:')) {
|
||||||
|
$node->parentNode?->removeChild($node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $media->findByFileName(substr($src, 6));
|
$item = $media->findByFileName(substr($src, 6));
|
||||||
if (!$item) {
|
if ($item === null) {
|
||||||
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$child->setAttribute('src', $item['url']);
|
$node->setAttribute('src', $item['url']);
|
||||||
$child->setAttribute('alt', trim((string) $child->getAttribute('alt')) ?: (string) $item['alt']);
|
$node->setAttribute('alt', trim((string) $node->getAttribute('alt')) ?: (string) $item['alt']);
|
||||||
$child->setAttribute('width', (string) $item['width']);
|
$node->setAttribute('width', (string) $item['width']);
|
||||||
$child->setAttribute('height', (string) $item['height']);
|
$node->setAttribute('height', (string) $item['height']);
|
||||||
$child->setAttribute('loading', 'lazy');
|
$node->setAttribute('loading', 'lazy');
|
||||||
$child->setAttribute('decoding', 'async');
|
$node->setAttribute('decoding', 'async');
|
||||||
}
|
|
||||||
|
|
||||||
$this->sanitizeChildren($child, $media);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unwrap(DOMElement $node): void
|
private function unwrap(DOMElement $node): void
|
||||||
@@ -131,16 +142,14 @@ class MarkdownService extends Prefab
|
|||||||
$parent->removeChild($node);
|
$parent->removeChild($node);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function allowedHref(string $href): bool
|
private function isAllowedHref(string $href): bool
|
||||||
{
|
{
|
||||||
if ($href === '') {
|
if ($href === '' || str_starts_with($href, '//')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($href, '#') || str_starts_with($href, '/')) {
|
if (str_starts_with($href, '#') || str_starts_with($href, '/')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('~^(?:https?://|mailto:)~i', $href)) {
|
if (preg_match('~^(?:https?://|mailto:)~i', $href)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
</true>
|
</true>
|
||||||
<false>
|
<false>
|
||||||
<section class="empty-state" aria-labelledby="dashboard-empty-title">
|
<section class="empty-state" aria-labelledby="dashboard-empty-title">
|
||||||
<h2 class="card-title" id="dashboard-empty-title">Aucun article</h2>
|
<h2 class="card-title" id="dashboard-empty-title">Aucun article.</h2>
|
||||||
<p>Commence par créer un premier article.</p>
|
<p>Crée un article pour commencer.</p>
|
||||||
</section>
|
</section>
|
||||||
</false>
|
</false>
|
||||||
</check>
|
</check>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title" id="media-title">Médiathèque</h1>
|
<h1 class="page-title" id="media-title">Médiathèque</h1>
|
||||||
<p class="field-help">Parcourir les images par page évite de charger toute la bibliothèque d'un coup.</p>
|
<p class="field-help">Ajoute une image ou copie son Markdown pour l’insérer dans un article.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
|
<form class="panel stack" method="post" action="{{ 'media_upload' | alias }}" enctype="multipart/form-data">
|
||||||
<include href="partials/csrf_field.html" />
|
<include href="partials/csrf_field.html" />
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Nouvelle image</span>
|
<span class="field-label">Image</span>
|
||||||
<input class="control" type="file" name="image" accept="image/jpeg,image/png" required>
|
<input class="control" type="file" name="image" accept="image/jpeg,image/png" required>
|
||||||
<span class="field-help">Formats : JPG, PNG. Taille maximale : 10 Mo.</span>
|
<span class="field-help">Formats : JPG, PNG. Taille maximale : 10 Mo.</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="button" type="submit">Envoyer</button>
|
<button class="button" type="submit">Ajouter</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<check if="{{ @items }}">
|
<check if="{{ @items }}">
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
</true>
|
</true>
|
||||||
<false>
|
<false>
|
||||||
<section class="empty-state" aria-labelledby="media-empty-title">
|
<section class="empty-state" aria-labelledby="media-empty-title">
|
||||||
<h2 class="card-title" id="media-empty-title">Aucune image</h2>
|
<h2 class="card-title" id="media-empty-title">Aucune image.</h2>
|
||||||
<p>Ajoute ta première image.</p>
|
<p>Ajoute une image pour commencer.</p>
|
||||||
</section>
|
</section>
|
||||||
</false>
|
</false>
|
||||||
</check>
|
</check>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="editor-layout" data-editor-layout>
|
|
||||||
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
||||||
<include href="partials/csrf_field.html" />
|
<include href="partials/csrf_field.html" />
|
||||||
|
|
||||||
@@ -27,59 +26,25 @@
|
|||||||
<div class="field-head">
|
<div class="field-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="field-label">Contenu</h2>
|
<h2 class="field-label">Contenu</h2>
|
||||||
<p class="field-help">Markdown avec insertion d’image au curseur. La première image du contenu est utilisée dans les cartes d’article.</p>
|
<p class="field-help">Rédige en Markdown. La première image du contenu sert de visuel dans les listes d’articles.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar" role="toolbar" aria-label="Outils Markdown">
|
<div class="toolbar" role="toolbar" aria-label="Outils Markdown">
|
||||||
<button class="tool-button" type="button" data-md-action="bold"><strong>Gras</strong></button>
|
<button class="button button--ghost button--small" type="button" data-md-action="bold"><strong>Gras</strong></button>
|
||||||
<button class="tool-button" type="button" data-md-action="italic"><em>Italique</em></button>
|
<button class="button button--ghost button--small" type="button" data-md-action="italic"><em>Italique</em></button>
|
||||||
<button class="tool-button" type="button" data-md-action="heading">Titre</button>
|
<button class="button button--ghost button--small" type="button" data-md-action="heading">Titre</button>
|
||||||
<button class="tool-button" type="button" data-md-action="list">Liste</button>
|
<button class="button button--ghost button--small" type="button" data-md-action="list">Liste</button>
|
||||||
<button class="tool-button" type="button" data-md-action="quote">Citation</button>
|
<button class="button button--ghost button--small" type="button" data-md-action="quote">Citation</button>
|
||||||
<button class="tool-button" type="button" data-md-action="link">Lien</button>
|
<button class="button button--ghost button--small" type="button" data-md-action="link">Lien</button>
|
||||||
<button class="tool-button" type="button" data-md-action="code">Code</button>
|
<button class="button button--ghost button--small" type="button" data-md-action="code">Code</button>
|
||||||
<button class="tool-button" type="button" data-media-picker-open>Image</button>
|
<a class="button button--ghost button--small" href="{{ 'media_index' | alias }}" target="_blank" rel="noopener">Ouvrir la médiathèque</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
|
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
|
||||||
<p class="field-help">Laisse une ligne vide entre deux blocs Markdown (titre, liste, citation, image, code).</p>
|
<p class="field-help">Laisse une ligne vide entre deux blocs. Pour ajouter une image, ouvre la médiathèque, copie le Markdown, puis colle-le ici.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button class="button" type="submit">Enregistrer</button>
|
<button class="button" type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="media-picker is-hidden" data-media-picker>
|
|
||||||
<div class="media-picker__head">
|
|
||||||
<div>
|
|
||||||
<strong data-media-picker-title>Insérer une image</strong>
|
|
||||||
<p class="field-help" data-media-picker-help>Clique sur une image pour l’insérer dans l’article.</p>
|
|
||||||
</div>
|
|
||||||
<button class="button button--ghost button--small" type="button" data-media-picker-close>Fermer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<check if="{{ @mediaItems }}">
|
|
||||||
<true>
|
|
||||||
<div class="media-picker__grid">
|
|
||||||
<repeat group="{{ @mediaItems }}" value="{{ @item }}">
|
|
||||||
<button class="media-picker__item" type="button" data-media-picker-select data-media-markdown="{{ @item.markdown }}">
|
|
||||||
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
|
|
||||||
</button>
|
|
||||||
</repeat>
|
|
||||||
</div>
|
|
||||||
<check if="{{ @mediaPickerTruncated }}">
|
|
||||||
<true>
|
|
||||||
<p class="field-help">Affichage limité aux {{ @mediaPickerLimit }} images les plus récentes sur {{ @mediaCount }}. Utilise la <a href="{{ 'media_index' | alias }}">médiathèque</a> pour parcourir toute la bibliothèque.</p>
|
|
||||||
</true>
|
|
||||||
</check>
|
|
||||||
</true>
|
|
||||||
<false>
|
|
||||||
<section class="empty-state" aria-labelledby="media-picker-empty-title">
|
|
||||||
<h2 class="card-title" id="media-picker-empty-title">Aucune image disponible</h2>
|
|
||||||
<p>Ajoute une image depuis la médiathèque.</p>
|
|
||||||
</section>
|
|
||||||
</false>
|
|
||||||
</check>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,19 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ @errorTitle ?: 'Erreur' }}</title>
|
<title>{{ @errorTitle }} · {{ @app.name }}</title>
|
||||||
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
|
<link rel="stylesheet" href="{{ @BASE }}/assets/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page error-page">
|
<main class="page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="error-card">
|
<section class="empty-state stack" aria-labelledby="error-title">
|
||||||
<p class="error-page__code">Erreur {{ @errorCode ?: 500 }}</p>
|
<p class="meta-text">Erreur {{ @errorCode }}</p>
|
||||||
<h1 class="error-page__title">{{ @errorTitle ?: 'Erreur' }}</h1>
|
<h1 class="page-title" id="error-title">{{ @errorTitle }}</h1>
|
||||||
<p class="error-page__message">{{ @errorMessage ?: 'Une erreur est survenue.' }}</p>
|
<p>{{ @errorMessage }}</p>
|
||||||
<p class="error-page__hint">Vérifie l’adresse ou reviens à l’accueil.</p>
|
<a class="button button--ghost" href="{{ 'home' | alias }}">Retour à l’accueil</a>
|
||||||
<p class="error-page__actions"><a class="button" href="{{ 'home' | alias }}">Retour à l’accueil</a></p>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<include href="partials/site_navigation.html" />
|
<include href="partials/site_navigation.html" />
|
||||||
|
|
||||||
<main class="page" id="main-content">
|
<main class="page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<check if="{{ @flash }}">
|
<check if="{{ @flash }}">
|
||||||
<repeat group="{{ @flash }}" value="{{ @msg }}">
|
<repeat group="{{ @flash }}" value="{{ @msg }}">
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ @CSRF_TOKEN }}">
|
<input type="hidden" name="csrf" value="{{ @CSRF }}">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<include href="partials/csrf_field.html" />
|
<include href="partials/csrf_field.html" />
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Texte alternatif</span>
|
<span class="field-label">Texte alternatif</span>
|
||||||
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Description de l'image" data-alt-input>
|
<input class="control" type="text" name="alt" value="{{ @item.alt }}" placeholder="Décris l’image" data-alt-input>
|
||||||
</label>
|
</label>
|
||||||
<button class="button button--ghost button--small" type="submit">Enregistrer</button>
|
<button class="button button--ghost button--small" type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
<input class="nav-toggle" type="checkbox" id="nav-toggle" aria-hidden="true">
|
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container site-header__inner">
|
<div class="container site-header__inner">
|
||||||
<label class="nav-toggle-button" for="nav-toggle">
|
<button
|
||||||
|
class="icon-button nav-toggle-button"
|
||||||
|
type="button"
|
||||||
|
data-mobile-menu-open
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Ouvrir le menu"
|
||||||
|
>
|
||||||
<span class="sr-only">Ouvrir le menu</span>
|
<span class="sr-only">Ouvrir le menu</span>
|
||||||
<span class="nav-toggle-button__line"></span>
|
<span class="nav-toggle-button__line"></span>
|
||||||
<span class="nav-toggle-button__line"></span>
|
<span class="nav-toggle-button__line"></span>
|
||||||
<span class="nav-toggle-button__line"></span>
|
<span class="nav-toggle-button__line"></span>
|
||||||
</label>
|
</button>
|
||||||
|
|
||||||
<div class="site-brand site-brand--header">
|
<div class="site-brand site-brand--header">
|
||||||
<include href="partials/site_brand.html" />
|
<include href="partials/site_brand.html" />
|
||||||
@@ -16,25 +21,23 @@
|
|||||||
<nav class="nav nav--desktop" aria-label="Navigation principale">
|
<nav class="nav nav--desktop" aria-label="Navigation principale">
|
||||||
<include href="partials/nav_items.html" />
|
<include href="partials/nav_items.html" />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<span class="site-header__spacer" aria-hidden="true"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mobile-menu">
|
<div class="mobile-menu" id="mobile-menu" data-mobile-menu>
|
||||||
<label class="mobile-menu__backdrop" for="nav-toggle" aria-hidden="true"></label>
|
<button class="mobile-menu__backdrop" type="button" data-mobile-menu-close aria-label="Fermer le menu"></button>
|
||||||
|
|
||||||
<div class="mobile-menu__panel">
|
<div class="mobile-menu__panel" role="dialog" aria-modal="true" aria-label="Navigation principale mobile">
|
||||||
<header class="mobile-menu__header">
|
<header class="mobile-menu__header">
|
||||||
<div class="site-brand site-brand--menu">
|
<div class="site-brand site-brand--menu">
|
||||||
<include href="partials/site_brand.html" />
|
<include href="partials/site_brand.html" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="mobile-menu__close" for="nav-toggle">
|
<button class="icon-button mobile-menu__close" type="button" data-mobile-menu-close aria-label="Fermer le menu">
|
||||||
<span class="sr-only">Fermer le menu</span>
|
<span class="sr-only">Fermer le menu</span>
|
||||||
<span class="mobile-menu__close-line"></span>
|
<span class="mobile-menu__close-line"></span>
|
||||||
<span class="mobile-menu__close-line"></span>
|
<span class="mobile-menu__close-line"></span>
|
||||||
</label>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="mobile-menu__nav" aria-label="Navigation principale mobile">
|
<nav class="mobile-menu__nav" aria-label="Navigation principale mobile">
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
</true>
|
</true>
|
||||||
<false>
|
<false>
|
||||||
<section class="empty-state" aria-labelledby="home-empty-title">
|
<section class="empty-state" aria-labelledby="home-empty-title">
|
||||||
<h2 class="card-title" id="home-empty-title">Aucun article</h2>
|
<h2 class="card-title" id="home-empty-title">Aucun article.</h2>
|
||||||
<p>Le premier article arrivera bientôt.</p>
|
<p>Reviens bientôt.</p>
|
||||||
</section>
|
</section>
|
||||||
</false>
|
</false>
|
||||||
</check>
|
</check>
|
||||||
|
|||||||
@@ -24,20 +24,14 @@ if (is_file($root . '/config.local.ini')) {
|
|||||||
$f3->config($root . '/config.local.ini');
|
$f3->config($root . '/config.local.ini');
|
||||||
}
|
}
|
||||||
|
|
||||||
date_default_timezone_set(app_timezone((string) $f3->get('app.timezone')));
|
$f3->set('TZ', app_timezone((string) $f3->get('app.timezone')));
|
||||||
$f3->set('TZ', date_default_timezone_get());
|
|
||||||
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
|
$f3->set('DEBUG', $f3->get('app.env') === 'prod' ? 0 : 3);
|
||||||
|
|
||||||
foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir')] as $dir) {
|
$uploadsDir = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
|
||||||
if (!is_dir($dir)) {
|
$f3->set('UPLOADS', $uploadsDir . '/');
|
||||||
mkdir($dir, 0775, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$uploads = $root . '/' . trim((string) $f3->get('UPLOADS'), '/');
|
foreach ([(string) $f3->get('TEMP'), (string) $f3->get('LOGS'), dirname((string) $f3->get('paths.db')), (string) $f3->get('paths.media_dir'), $uploadsDir] as $dir) {
|
||||||
$f3->set('UPLOADS', $uploads . '/');
|
app_ensure_dir($dir);
|
||||||
if (!is_dir($uploads)) {
|
|
||||||
mkdir($uploads, 0775, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ini_set('log_errors', '1');
|
ini_set('log_errors', '1');
|
||||||
@@ -49,16 +43,15 @@ $db = new DB\SQL('sqlite:' . $f3->get('paths.db'));
|
|||||||
$db->exec('PRAGMA foreign_keys = ON');
|
$db->exec('PRAGMA foreign_keys = ON');
|
||||||
$f3->set('DB', $db);
|
$f3->set('DB', $db);
|
||||||
|
|
||||||
$secure = app_request_is_secure();
|
|
||||||
session_name((string) $f3->get('app.session_name'));
|
session_name((string) $f3->get('app.session_name'));
|
||||||
$f3->set('JAR', [
|
$f3->set('JAR', [
|
||||||
'expire' => 0,
|
'expire' => 0,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => $secure,
|
'secure' => app_request_is_secure(),
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
new Session(null, 'CSRF');
|
new Session();
|
||||||
|
|
||||||
Template::instance()->filter('date_fr', 'app_date_fr');
|
Template::instance()->filter('date_fr', 'app_date_fr');
|
||||||
|
|
||||||
@@ -66,6 +59,7 @@ if ($f3->get('app.env') === 'prod') {
|
|||||||
$f3->set('ONERROR', function (Base $f3): void {
|
$f3->set('ONERROR', function (Base $f3): void {
|
||||||
$code = max(1, (int) ($f3->get('ERROR.code') ?: 500));
|
$code = max(1, (int) ($f3->get('ERROR.code') ?: 500));
|
||||||
$meta = app_error_meta($code);
|
$meta = app_error_meta($code);
|
||||||
|
|
||||||
$f3->status($code);
|
$f3->status($code);
|
||||||
$f3->expire(0);
|
$f3->expire(0);
|
||||||
$f3->mset([
|
$f3->mset([
|
||||||
@@ -73,6 +67,7 @@ if ($f3->get('app.env') === 'prod') {
|
|||||||
'errorTitle' => $meta['title'],
|
'errorTitle' => $meta['title'],
|
||||||
'errorMessage' => $meta['message'],
|
'errorMessage' => $meta['message'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
echo Template::instance()->render('errors/error.html');
|
echo Template::instance()->render('errors/error.html');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
function app_timezone(string $value): string
|
function app_timezone(string $value): string
|
||||||
{
|
{
|
||||||
$value = trim($value);
|
$timezone = trim($value);
|
||||||
return in_array($value, DateTimeZone::listIdentifiers(), true) ? $value : 'UTC';
|
return in_array($timezone, DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC';
|
||||||
}
|
}
|
||||||
|
|
||||||
function app_now(): string
|
function app_now(): string
|
||||||
@@ -52,14 +52,13 @@ function app_date_fr(string $value): string
|
|||||||
function app_error_meta(int $code): array
|
function app_error_meta(int $code): array
|
||||||
{
|
{
|
||||||
return match ($code) {
|
return match ($code) {
|
||||||
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
|
400 => ['title' => 'Requête invalide', 'message' => 'La requête est invalide.'],
|
||||||
403 => ['title' => 'Accès refusé', 'message' => 'Tu n’as pas accès à cette ressource.'],
|
403 => ['title' => 'Accès refusé', 'message' => 'Tu n’as pas accès à cette page.'],
|
||||||
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
|
404 => ['title' => 'Page introuvable', 'message' => 'La page est introuvable.'],
|
||||||
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function app_request_is_secure(): bool
|
function app_request_is_secure(): bool
|
||||||
{
|
{
|
||||||
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||||
@@ -77,3 +76,10 @@ function app_request_is_secure(): bool
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function app_ensure_dir(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
mkdir($path, Base::MODE, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Mapping local pratique pour un reverse proxy Caddy sur l'hôte.
|
# Mapping local pratique pour un reverse proxy Caddy sur l'hôte.
|
||||||
# Si Caddy tourne aussi dans Docker sur le même réseau, ce port peut être
|
# Si Caddy tourne aussi dans Docker, le proxy peut cibler directement app:80.
|
||||||
# supprimé et le proxy peut cibler directement app:80.
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8888:80"
|
- "127.0.0.1:8888:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
"ext-intl": "*",
|
"ext-intl": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-pdo_sqlite": "*",
|
"ext-pdo_sqlite": "*",
|
||||||
|
|||||||
10
composer.lock
generated
10
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "044d603783cee3e8e1846aab9a07be1f",
|
"content-hash": "0b85b0833c8e05013fbd10fcc6a9ce9d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bcosca/fatfree-core",
|
"name": "bcosca/fatfree-core",
|
||||||
@@ -59,8 +59,12 @@
|
|||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.3",
|
"ext-dom": "*",
|
||||||
"ext-intl": "*"
|
"ext-gd": "*",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-pdo_sqlite": "*",
|
||||||
|
"php": "^8.3"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ set -eu
|
|||||||
APP_ROOT="/var/www/html"
|
APP_ROOT="/var/www/html"
|
||||||
CONFIG="$APP_ROOT/config.local.ini"
|
CONFIG="$APP_ROOT/config.local.ini"
|
||||||
|
|
||||||
# Docker crée un répertoire si le fichier hôte n'existe pas lors du bind mount.
|
# Si le bind mount cible un fichier absent, Docker crée un répertoire.
|
||||||
# Le supprimer pour que bootstrap.php retombe sur les valeurs par défaut.
|
# Le supprimer pour laisser bootstrap.php utiliser les valeurs par défaut.
|
||||||
if [ -d "$CONFIG" ]; then
|
if [ -d "$CONFIG" ]; then
|
||||||
rmdir "$CONFIG" 2>/dev/null || true
|
rmdir "$CONFIG" 2>/dev/null || true
|
||||||
echo "Warning: config.local.ini monté comme répertoire (fichier absent sur l'hôte). Valeurs par défaut utilisées."
|
echo "Warning: config.local.ini monté comme répertoire (fichier absent sur l'hôte). Valeurs par défaut utilisées."
|
||||||
@@ -19,7 +19,7 @@ install -d -m 0775 -o www-data -g www-data \
|
|||||||
"$APP_ROOT/tmp/uploads"
|
"$APP_ROOT/tmp/uploads"
|
||||||
|
|
||||||
# Les bind mounts peuvent conserver les permissions de l'hôte.
|
# Les bind mounts peuvent conserver les permissions de l'hôte.
|
||||||
# Normaliser les dossiers persistants et le runtime éphémère avant le démarrage.
|
# Normaliser les dossiers persistants et le runtime avant le démarrage.
|
||||||
chown -R www-data:www-data \
|
chown -R www-data:www-data \
|
||||||
"$APP_ROOT/db" \
|
"$APP_ROOT/db" \
|
||||||
"$APP_ROOT/logs" \
|
"$APP_ROOT/logs" \
|
||||||
@@ -31,7 +31,7 @@ chmod -R u+rwX,g+rwX \
|
|||||||
"$APP_ROOT/public/uploads/media" \
|
"$APP_ROOT/public/uploads/media" \
|
||||||
"$APP_ROOT/tmp"
|
"$APP_ROOT/tmp"
|
||||||
|
|
||||||
# Exécuter l'installation en tant que www-data pour conserver des permissions cohérentes.
|
# Exécuter l'installation en tant que www-data pour garder des permissions cohérentes.
|
||||||
su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php"
|
su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php"
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/* =========================================================
|
/* Design tokens */
|
||||||
Design tokens
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #f6f8fb;
|
--color-bg: #f6f8fb;
|
||||||
@@ -46,9 +44,7 @@
|
|||||||
--focus-ring: 0 0 0 4px rgba(18, 32, 51, 0.1);
|
--focus-ring: 0 0 0 4px rgba(18, 32, 51, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Base */
|
||||||
Base
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -87,9 +83,7 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Layout helpers and utilities */
|
||||||
Layout helpers and utilities
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: min(100% - 2rem, var(--container));
|
width: min(100% - 2rem, var(--container));
|
||||||
@@ -116,13 +110,7 @@ a {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-hidden {
|
/* layout.html */
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
layout.html
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -141,41 +129,19 @@ a {
|
|||||||
padding: var(--space-4) 0;
|
padding: var(--space-4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header__spacer {
|
|
||||||
display: none;
|
|
||||||
width: 3rem;
|
|
||||||
min-width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand {
|
.site-brand {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-brand__title {
|
.site-brand__title {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-brand--header {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-brand--menu {
|
.site-brand--menu {
|
||||||
padding-right: var(--space-4);
|
padding-right: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle {
|
|
||||||
position: fixed;
|
|
||||||
inline-size: 1px;
|
|
||||||
block-size: 1px;
|
|
||||||
inset: 0 auto auto 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav,
|
.nav,
|
||||||
.page-actions,
|
.page-actions,
|
||||||
.card-actions {
|
.card-actions {
|
||||||
@@ -209,7 +175,6 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-items__link {
|
.nav-items__link {
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +183,7 @@ a {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: rendre Déconnexion visuellement identique à Connexion */
|
/* Alignement visuel du bouton de déconnexion sur les liens desktop */
|
||||||
.nav--desktop .nav-items__button {
|
.nav--desktop .nav-items__button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -230,7 +195,6 @@ a {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
@@ -243,8 +207,9 @@ a {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-button,
|
.icon-button {
|
||||||
.mobile-menu__close {
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -258,20 +223,16 @@ a {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
cursor: pointer;
|
|
||||||
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-button:hover,
|
.icon-button:hover,
|
||||||
.nav-toggle-button:focus-visible,
|
.icon-button:focus-visible {
|
||||||
.mobile-menu__close:hover,
|
|
||||||
.mobile-menu__close:focus-visible {
|
|
||||||
border-color: var(--color-border-strong);
|
border-color: var(--color-border-strong);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-button:active,
|
.icon-button:active {
|
||||||
.mobile-menu__close:active {
|
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,8 +276,11 @@ a {
|
|||||||
.mobile-menu__backdrop {
|
.mobile-menu__backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
background: rgba(18, 32, 51, 0.46);
|
background: rgba(18, 32, 51, 0.46);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
transition: opacity 220ms ease;
|
transition: opacity 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,15 +308,6 @@ a {
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-menu__title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu__nav {
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu__nav .nav-items {
|
.mobile-menu__nav .nav-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@@ -377,7 +332,6 @@ a {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,20 +343,20 @@ a {
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-toggle:checked ~ .mobile-menu {
|
.mobile-menu.is-open {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-toggle:checked ~ .mobile-menu .mobile-menu__backdrop,
|
.mobile-menu.is-open .mobile-menu__backdrop,
|
||||||
#nav-toggle:checked ~ .mobile-menu .mobile-menu__panel {
|
.mobile-menu.is-open .mobile-menu__panel {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-toggle:checked ~ .mobile-menu .mobile-menu__panel {
|
.mobile-menu.is-open .mobile-menu__panel {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-toggle:checked ~ .site-header .nav-toggle-button {
|
.site-header.is-mobile-menu-open .nav-toggle-button {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -438,9 +392,7 @@ a {
|
|||||||
border-color: var(--color-danger-border);
|
border-color: var(--color-danger-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Shared sections and components */
|
||||||
Shared sections and components
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -465,29 +417,20 @@ a {
|
|||||||
.card,
|
.card,
|
||||||
.panel,
|
.panel,
|
||||||
.prose,
|
.prose,
|
||||||
.empty-state,
|
.empty-state {
|
||||||
.media-picker,
|
|
||||||
.auth-shell,
|
|
||||||
.error-card {
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card,
|
.card,
|
||||||
.panel,
|
.panel,
|
||||||
.prose,
|
.prose {
|
||||||
.media-picker,
|
|
||||||
.auth-shell,
|
|
||||||
.error-card {
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
.prose,
|
.prose {
|
||||||
.media-picker,
|
|
||||||
.auth-shell,
|
|
||||||
.error-card {
|
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,8 +440,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-grid,
|
.card-grid,
|
||||||
.field,
|
.field {
|
||||||
.media-picker__grid {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
@@ -547,28 +489,18 @@ textarea.control {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button,
|
.button {
|
||||||
.tool-button,
|
|
||||||
.media-picker__item {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
text-decoration: none;
|
|
||||||
transition: transform var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button,
|
|
||||||
.tool-button {
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
min-height: var(--control-height);
|
min-height: var(--control-height);
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
border: 1px solid var(--color-text);
|
border: 1px solid var(--color-text);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
background: var(--color-text);
|
background: var(--color-text);
|
||||||
color: var(--color-surface);
|
color: var(--color-surface);
|
||||||
|
transition: transform var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover,
|
.button:hover,
|
||||||
@@ -576,9 +508,7 @@ textarea.control {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--ghost,
|
.button--ghost {
|
||||||
.tool-button,
|
|
||||||
.media-picker__item {
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -595,16 +525,7 @@ textarea.control {
|
|||||||
padding: 0.45rem 0.8rem;
|
padding: 0.45rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button {
|
.button--ghost:focus-visible {
|
||||||
min-height: 2.5rem;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button--ghost:focus-visible,
|
|
||||||
.tool-button:hover,
|
|
||||||
.tool-button:focus-visible,
|
|
||||||
.media-picker__item:hover,
|
|
||||||
.media-picker__item:focus-visible {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-border-strong);
|
border-color: var(--color-border-strong);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--focus-ring);
|
||||||
@@ -618,10 +539,6 @@ textarea.control {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-frame--square {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-frame--placeholder {
|
.media-frame--placeholder {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -673,9 +590,7 @@ textarea.control {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Public pages */
|
||||||
Public pages
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.article {
|
.article {
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
@@ -751,24 +666,7 @@ textarea.control {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Admin pages */
|
||||||
Admin pages
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.editor-layout {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-layout.is-picker-open {
|
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-form {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-textarea {
|
.editor-textarea {
|
||||||
min-height: 22rem;
|
min-height: 22rem;
|
||||||
@@ -784,85 +682,14 @@ textarea.control {
|
|||||||
background: var(--color-surface-muted);
|
background: var(--color-surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-picker {
|
/* Auth pages */
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
background: var(--color-surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-layout.is-picker-open .media-picker {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker__grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-picker__item {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
text-align: left;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Auth and error pages
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
width: min(100%, var(--container-narrow));
|
width: min(100%, var(--container-narrow));
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-page {
|
/* Pagination */
|
||||||
padding: var(--space-8) 0 var(--space-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-page__code {
|
|
||||||
margin: 0 0 var(--space-3);
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-page__title {
|
|
||||||
margin: 0 0 var(--space-4);
|
|
||||||
font-size: clamp(2rem, 2vw + 1rem, 3rem);
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-page__message,
|
|
||||||
.error-page__hint {
|
|
||||||
margin: 0 0 var(--space-4);
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-page__actions {
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Pagination
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -882,16 +709,7 @@ textarea.control {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Responsive */
|
||||||
Responsive
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
|
||||||
.editor-layout,
|
|
||||||
.editor-layout.is-picker-open {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
@@ -927,10 +745,7 @@ textarea.control {
|
|||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
.prose,
|
.prose,
|
||||||
.media-picker,
|
.empty-state {
|
||||||
.empty-state,
|
|
||||||
.auth-shell,
|
|
||||||
.error-card {
|
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +757,6 @@ textarea.control {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-button,
|
|
||||||
.button--small {
|
.button--small {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -954,9 +768,7 @@ textarea.control {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* Responsive navigation */
|
||||||
Responsive navigation
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.site-header {
|
.site-header {
|
||||||
@@ -972,6 +784,13 @@ textarea.control {
|
|||||||
min-height: 5.25rem;
|
min-height: 5.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-header__inner::after {
|
||||||
|
content: "";
|
||||||
|
grid-column: 3;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.site-brand--header {
|
.site-brand--header {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
@@ -987,9 +806,7 @@ textarea.control {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-button,
|
.icon-button {
|
||||||
.mobile-menu__close,
|
|
||||||
.site-header__spacer {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,11 +815,6 @@ textarea.control {
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header__spacer {
|
|
||||||
grid-column: 3;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
padding-top: var(--space-6);
|
padding-top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,50 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const on = (selector, handler) => document.querySelectorAll(selector).forEach(handler);
|
const each = (selector, handler) => document.querySelectorAll(selector).forEach(handler);
|
||||||
|
|
||||||
on('[data-copy-text]', (button) => {
|
|
||||||
|
const siteHeader = document.querySelector('.site-header');
|
||||||
|
const mobileMenu = document.querySelector('[data-mobile-menu]');
|
||||||
|
const mobileMenuOpen = document.querySelector('[data-mobile-menu-open]');
|
||||||
|
|
||||||
|
if (siteHeader && mobileMenu && mobileMenuOpen) {
|
||||||
|
const setMobileMenuState = (isOpen) => {
|
||||||
|
mobileMenu.classList.toggle('is-open', isOpen);
|
||||||
|
siteHeader.classList.toggle('is-mobile-menu-open', isOpen);
|
||||||
|
mobileMenuOpen.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
|
||||||
|
mobileMenuOpen.addEventListener('click', () => {
|
||||||
|
setMobileMenuState(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
each('[data-mobile-menu-close]', (button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
setMobileMenuState(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && mobileMenu.classList.contains('is-open')) {
|
||||||
|
setMobileMenuState(false);
|
||||||
|
mobileMenuOpen.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(min-width: 721px)');
|
||||||
|
const handleViewportChange = (event) => {
|
||||||
|
if (event.matches) {
|
||||||
|
setMobileMenuState(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === 'function') {
|
||||||
|
mediaQuery.addEventListener('change', handleViewportChange);
|
||||||
|
} else if (typeof mediaQuery.addListener === 'function') {
|
||||||
|
mediaQuery.addListener(handleViewportChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
each('[data-copy-text]', (button) => {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
const text = button.getAttribute('data-copy-text') || '';
|
const text = button.getAttribute('data-copy-text') || '';
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -21,7 +64,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
on('[data-confirm]', (form) => {
|
each('[data-confirm]', (form) => {
|
||||||
form.addEventListener('submit', (event) => {
|
form.addEventListener('submit', (event) => {
|
||||||
const message = form.getAttribute('data-confirm') || 'Confirmer cette action ?';
|
const message = form.getAttribute('data-confirm') || 'Confirmer cette action ?';
|
||||||
if (!window.confirm(message)) {
|
if (!window.confirm(message)) {
|
||||||
@@ -30,7 +73,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
on('[data-char-count]', (field) => {
|
each('[data-char-count]', (field) => {
|
||||||
const counter = field.parentElement?.querySelector('[data-char-count-value]');
|
const counter = field.parentElement?.querySelector('[data-char-count-value]');
|
||||||
if (!counter) {
|
if (!counter) {
|
||||||
return;
|
return;
|
||||||
@@ -45,23 +88,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const editor = document.querySelector('[data-markdown-editor]');
|
const editor = document.querySelector('[data-markdown-editor]');
|
||||||
const picker = document.querySelector('[data-media-picker]');
|
|
||||||
const editorLayout = document.querySelector('[data-editor-layout]');
|
|
||||||
const pickerClose = document.querySelector('[data-media-picker-close]');
|
|
||||||
|
|
||||||
const focusEditor = () => editor?.focus();
|
|
||||||
|
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
const focusEditor = () => editor.focus();
|
||||||
|
|
||||||
const replaceSelection = (before, after = '', placeholder = '') => {
|
const replaceSelection = (before, after = '', placeholder = '') => {
|
||||||
const start = editor.selectionStart;
|
const start = editor.selectionStart;
|
||||||
const end = editor.selectionEnd;
|
const end = editor.selectionEnd;
|
||||||
const selected = editor.value.slice(start, end);
|
const selection = editor.value.slice(start, end);
|
||||||
const content = selected || placeholder;
|
const content = selection || placeholder;
|
||||||
const insertion = before + content + after;
|
const insertion = before + content + after;
|
||||||
|
|
||||||
editor.setRangeText(insertion, start, end, 'end');
|
editor.setRangeText(insertion, start, end, 'end');
|
||||||
|
|
||||||
if (!selected && placeholder) {
|
if (!selection && placeholder) {
|
||||||
const cursorStart = start + before.length;
|
const cursorStart = start + before.length;
|
||||||
editor.setSelectionRange(cursorStart, cursorStart + placeholder.length);
|
editor.setSelectionRange(cursorStart, cursorStart + placeholder.length);
|
||||||
}
|
}
|
||||||
@@ -72,17 +111,17 @@
|
|||||||
const prefixLines = (prefix, placeholder) => {
|
const prefixLines = (prefix, placeholder) => {
|
||||||
const start = editor.selectionStart;
|
const start = editor.selectionStart;
|
||||||
const end = editor.selectionEnd;
|
const end = editor.selectionEnd;
|
||||||
const selected = editor.value.slice(start, end) || placeholder;
|
const selection = editor.value.slice(start, end) || placeholder;
|
||||||
const prefixed = selected
|
const value = selection
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => (line ? prefix + line : prefix.trimEnd()))
|
.map((line) => (line ? prefix + line : prefix.trimEnd()))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
editor.setRangeText(prefixed, start, end, 'end');
|
editor.setRangeText(value, start, end, 'end');
|
||||||
focusEditor();
|
focusEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
on('[data-md-action]', (button) => {
|
each('[data-md-action]', (button) => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
switch (button.getAttribute('data-md-action')) {
|
switch (button.getAttribute('data-md-action')) {
|
||||||
case 'bold':
|
case 'bold':
|
||||||
@@ -104,12 +143,8 @@
|
|||||||
replaceSelection('[', '](https://)', 'texte');
|
replaceSelection('[', '](https://)', 'texte');
|
||||||
break;
|
break;
|
||||||
case 'code': {
|
case 'code': {
|
||||||
const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
const selection = editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||||
replaceSelection(
|
replaceSelection(selection.includes('\n') ? '```\n' : '`', selection.includes('\n') ? '\n```' : '`', 'code');
|
||||||
selected.includes('\n') ? '```\n' : '`',
|
|
||||||
selected.includes('\n') ? '\n```' : '`',
|
|
||||||
'code'
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,45 +152,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePicker = (open) => {
|
each('[data-alt-input]', (input) => {
|
||||||
if (!picker) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.classList.toggle('is-hidden', !open);
|
|
||||||
editorLayout?.classList.toggle('is-picker-open', open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
focusEditor();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
};
|
|
||||||
|
|
||||||
on('[data-media-picker-open]', (button) => {
|
|
||||||
button.addEventListener('click', () => togglePicker(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
pickerClose?.addEventListener('click', () => togglePicker(false));
|
|
||||||
|
|
||||||
on('[data-media-picker-select]', (button) => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const markdown = button.getAttribute('data-media-markdown') || '';
|
|
||||||
if (!editor || !markdown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = editor.selectionStart;
|
|
||||||
const end = editor.selectionEnd;
|
|
||||||
const prefix = start > 0 && !editor.value.slice(0, start).endsWith('\n\n') ? '\n\n' : '';
|
|
||||||
const suffix = end < editor.value.length && !editor.value.slice(end).startsWith('\n\n') ? '\n\n' : '';
|
|
||||||
editor.setRangeText(prefix + markdown + suffix, start, end, 'end');
|
|
||||||
togglePicker(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
on('[data-alt-input]', (input) => {
|
|
||||||
const card = input.closest('.card');
|
const card = input.closest('.card');
|
||||||
const button = card?.querySelector('[data-markdown-template]');
|
const button = card?.querySelector('[data-markdown-template]');
|
||||||
if (!button) {
|
if (!button) {
|
||||||
@@ -164,8 +161,7 @@
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const template = button.getAttribute('data-markdown-template') || '';
|
const template = button.getAttribute('data-markdown-template') || '';
|
||||||
const alt = input.value;
|
button.setAttribute('data-copy-text', template.replace(');
|
||||||
button.setAttribute('data-copy-text', template.replace(');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener('input', update);
|
input.addEventListener('input', update);
|
||||||
|
|||||||
56
scripts/clean-orphan-media.php
Normal file
56
scripts/clean-orphan-media.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$f3 = require __DIR__ . '/bootstrap.php';
|
||||||
|
Media::bootstrap($f3->get('DB'));
|
||||||
|
|
||||||
|
$mode = strtolower(trim((string) ($argv[1] ?? 'list')));
|
||||||
|
if (!in_array($mode, ['list', 'delete'], true)) {
|
||||||
|
fwrite(STDERR, "Usage: php scripts/clean-orphan-media.php [list|delete]\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = rtrim((string) $f3->get('paths.media_dir'), '/\\');
|
||||||
|
$knownFiles = [];
|
||||||
|
foreach ((new Media())->find(null) ?: [] as $row) {
|
||||||
|
$knownFiles[(string) $row->file_name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orphans = [];
|
||||||
|
if (is_dir($directory)) {
|
||||||
|
foreach (glob($directory . DIRECTORY_SEPARATOR . '*') ?: [] as $path) {
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = basename($path);
|
||||||
|
if (!isset($knownFiles[$name])) {
|
||||||
|
$orphans[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($orphans, SORT_NATURAL);
|
||||||
|
|
||||||
|
if ($mode === 'list') {
|
||||||
|
if ($orphans === []) {
|
||||||
|
fwrite(STDOUT, "Aucun fichier orphelin.\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Fichiers orphelins:\n");
|
||||||
|
foreach ($orphans as $path) {
|
||||||
|
fwrite(STDOUT, '- ' . basename($path) . PHP_EOL);
|
||||||
|
}
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
foreach ($orphans as $path) {
|
||||||
|
if (@unlink($path)) {
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Fichiers orphelins supprimés : {$deleted}\n");
|
||||||
Reference in New Issue
Block a user