First commit
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
compose.yaml
|
||||||
|
config.local.ini
|
||||||
|
config.local.ini.example
|
||||||
|
vendor
|
||||||
|
db/*
|
||||||
|
!db/.gitkeep
|
||||||
|
logs/*
|
||||||
|
!logs/.gitkeep
|
||||||
|
tmp/*
|
||||||
|
!tmp/.gitkeep
|
||||||
|
public/uploads/media/*
|
||||||
|
!public/uploads/media/.gitkeep
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/db/*
|
||||||
|
!/db/.gitkeep
|
||||||
|
/logs/*
|
||||||
|
!/logs/.gitkeep
|
||||||
|
/vendor/
|
||||||
|
/tmp/*
|
||||||
|
!/tmp/.gitkeep
|
||||||
|
!/tmp/cache/
|
||||||
|
!/tmp/uploads/
|
||||||
|
/tmp/cache/*
|
||||||
|
!/tmp/cache/.gitkeep
|
||||||
|
/tmp/uploads/*
|
||||||
|
!/tmp/uploads/.gitkeep
|
||||||
|
/public/uploads/media/*
|
||||||
|
!/public/uploads/media/.gitkeep
|
||||||
|
/config.local.ini
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM composer:2 AS vendor
|
||||||
|
WORKDIR /app
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN --mount=type=cache,target=/tmp/composer-cache \
|
||||||
|
COMPOSER_CACHE_DIR=/tmp/composer-cache \
|
||||||
|
composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
|
||||||
|
|
||||||
|
FROM php:8.3-apache
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libonig-dev \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||||
|
&& docker-php-ext-install -j"$(nproc)" pdo_sqlite dom gd mbstring opcache \
|
||||||
|
&& printf 'ServerName localhost\n' > /etc/apache2/conf-available/servername.conf \
|
||||||
|
&& a2enconf servername \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY --from=vendor /app/vendor /var/www/html/vendor
|
||||||
|
COPY --chown=www-data:www-data . /var/www/html
|
||||||
|
COPY docker/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint
|
||||||
|
COPY docker/php-prod.ini /usr/local/etc/php/conf.d/zz-prod.ini
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/app-entrypoint
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD php -r '$fp=@fsockopen("127.0.0.1", 80); if (!$fp) { exit(1); } fclose($fp);'
|
||||||
|
|
||||||
|
ENTRYPOINT ["app-entrypoint"]
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
170
README.md
Normal file
170
README.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# F3 Simple Blog
|
||||||
|
|
||||||
|
Blog simple avec Fat-Free Framework.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
project/
|
||||||
|
├── config.local.ini # Surcharges locales (gitignored)
|
||||||
|
├── app/
|
||||||
|
│ ├── config.ini # Routes et variables F3
|
||||||
|
│ ├── bootstrap.php # Initialisation (DB, session, cache, erreurs)
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ ├── Helpers/ # Fonctions utilitaires (App.php, Error.php)
|
||||||
|
│ ├── Models/ # DB\SQL\Mapper (Post, Media, User)
|
||||||
|
│ ├── Services/ # MarkdownService
|
||||||
|
│ └── Views/
|
||||||
|
├── db/
|
||||||
|
│ └── app.sqlite
|
||||||
|
├── logs/
|
||||||
|
│ ├── app.log
|
||||||
|
│ └── php-error.log
|
||||||
|
├── public/
|
||||||
|
│ ├── assets/ # Sources CSS/JS (servis minifiés via /min/@file)
|
||||||
|
│ └── uploads/
|
||||||
|
│ └── media/ # Images converties en PNG (sans perte)
|
||||||
|
└── tmp/
|
||||||
|
├── cache/ # Cache F3 (pages publiques + assets minifiés)
|
||||||
|
└── uploads/ # Transit Web::receive() — vidé après chaque upload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Philosophie des dossiers runtime
|
||||||
|
|
||||||
|
Le projet garde la même logique en local et dans Docker :
|
||||||
|
|
||||||
|
- `tmp/` = runtime temporaire, recréable
|
||||||
|
- `db/` = base SQLite persistante
|
||||||
|
- `logs/` = logs persistants
|
||||||
|
- `public/uploads/media/` = médias publiés et persistants
|
||||||
|
|
||||||
|
Autrement dit, `tmp/` peut être vidé sans perte métier. Les données à sauvegarder restent hors de `tmp/`.
|
||||||
|
|
||||||
|
## Fonctionnalités F3 utilisées
|
||||||
|
|
||||||
|
- **Routage nommé** — `config.ini [routes]`, `$f3->alias()`
|
||||||
|
- **Cache HTTP + serveur** — `$f3->expire()` sur les routes publiques, `Cache::reset('.url')` à la mutation
|
||||||
|
- **Assets minifiés** — `Web::minify()` via `AssetController` (`GET /min/@file`)
|
||||||
|
- **Upload** — `Web::receive()` avec callback de validation taille
|
||||||
|
- **Images** — `Image` (chargement, conversion PNG, dimensions), `Base::write()` pour l'écriture
|
||||||
|
- **Markdown** — `Markdown::instance()->convert()`
|
||||||
|
- **Slugs** — `Web::instance()->slug()`
|
||||||
|
- **Session** — `$f3->set('JAR', …)`, token CSRF dans `SESSION.csrf_token`
|
||||||
|
- **Logging** — `Log` de F3 avec fallback `file_put_contents`
|
||||||
|
- **ORM** — `DB\SQL\Mapper` : `paginate()`, `copyfrom()`, `cast()`, `find()`
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
### Développement local
|
||||||
|
|
||||||
|
- PHP 8.3+
|
||||||
|
- Composer
|
||||||
|
- Extensions PHP : `pdo_sqlite`, `dom`, `gd`, `mbstring`
|
||||||
|
|
||||||
|
### Déploiement Docker
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Les paramètres par défaut sont dans `app/config.ini`.
|
||||||
|
|
||||||
|
Pour surcharger localement ou en production :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.local.ini.example config.local.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
Réglages minimums conseillés en production :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[globals]
|
||||||
|
app.env=prod
|
||||||
|
app.timezone=Europe/Paris
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fichier `config.local.ini` sert uniquement aux surcharges d'environnement. Les chemins runtime restent les mêmes partout :
|
||||||
|
|
||||||
|
- `tmp/cache/` pour le cache F3 et les assets minifiés
|
||||||
|
- `tmp/uploads/` pour les fichiers temporaires d'upload
|
||||||
|
|
||||||
|
## Développement local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
cp config.local.ini.example config.local.ini
|
||||||
|
php scripts/install.php
|
||||||
|
php -S 127.0.0.1:8080 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvre ensuite `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
|
Créer un compte admin :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/create-admin.php admin
|
||||||
|
# mot de passe : 10 caractères minimum
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement avec Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.local.ini.example config.local.ini
|
||||||
|
# édite config.local.ini (app.env=prod, app.timezone, etc.)
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker monte les mêmes dossiers runtime que le développement local. Seuls les répertoires persistants restent séparés de `tmp/`.
|
||||||
|
|
||||||
|
Si `config.local.ini` n'existe pas, le conteneur démarre avec les valeurs par défaut de `app/config.ini`.
|
||||||
|
|
||||||
|
Le service écoute sur `http://127.0.0.1:8888`.
|
||||||
|
|
||||||
|
Créer un compte admin :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php scripts/create-admin.php admin
|
||||||
|
# mot de passe : 10 caractères minimum
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse proxy Caddy
|
||||||
|
|
||||||
|
### Caddy sur le même hôte
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
blog.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy 127.0.0.1:8888
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy dans Docker
|
||||||
|
|
||||||
|
Si Caddy tourne aussi dans Docker, place-le sur le même réseau que `app` et cible directement le service :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
blog.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Données à sauvegarder
|
||||||
|
|
||||||
|
- `db/` — base SQLite
|
||||||
|
- `public/uploads/media/` — images
|
||||||
|
- `logs/` — optionnel
|
||||||
|
- `tmp/` — non persistant, recréable
|
||||||
|
|
||||||
|
## Mise à jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
- Applicatifs : `logs/app.log`
|
||||||
|
- PHP : `logs/php-error.log`
|
||||||
|
- Apache (conteneur) : `docker compose logs -f app`
|
||||||
30
app/Controllers/AssetController.php
Normal file
30
app/Controllers/AssetController.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AssetController extends BaseController
|
||||||
|
{
|
||||||
|
private const ALLOWED = [
|
||||||
|
'app.css' => 'text/css',
|
||||||
|
'app.js' => 'application/javascript',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function serve(): void
|
||||||
|
{
|
||||||
|
$file = basename((string) $this->f3->get('PARAMS.file'));
|
||||||
|
|
||||||
|
if (!array_key_exists($file, self::ALLOWED)) {
|
||||||
|
$this->f3->error(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->expire(86400); // 24 h côté navigateur
|
||||||
|
|
||||||
|
echo Web::instance()->minify(
|
||||||
|
$file,
|
||||||
|
self::ALLOWED[$file],
|
||||||
|
true, // envoie le Content-Type
|
||||||
|
app_root() . '/public/assets/' // répertoire source (hors UI)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Controllers/AuthController.php
Normal file
47
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
if ($this->currentUser() !== null) {
|
||||||
|
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->expire(0);
|
||||||
|
$this->render('auth/login.html', ['pageTitle' => 'Connexion']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
$username = trim((string) ($this->f3->get('POST.username') ?? ''));
|
||||||
|
$password = (string) ($this->f3->get('POST.password') ?? '');
|
||||||
|
|
||||||
|
$user = (new User($this->db))->findByUsername($username);
|
||||||
|
if ($user === null || !password_verify($password, $user['password_hash'])) {
|
||||||
|
usleep(1_500_000); // 1,5 s — ralentit le brute-force
|
||||||
|
$this->flash('error', 'Identifiants invalides.');
|
||||||
|
$this->f3->reroute($this->f3->alias('login'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$this->f3->set('SESSION.user_id', $user['id']);
|
||||||
|
$this->flash('success', 'Connexion réussie.');
|
||||||
|
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->verifyCsrf();
|
||||||
|
$this->f3->clear('SESSION.user_id');
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$this->flash('success', 'Déconnexion effectuée.');
|
||||||
|
$this->f3->reroute($this->f3->alias('home'));
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Controllers/BaseController.php
Normal file
79
app/Controllers/BaseController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
abstract class BaseController
|
||||||
|
{
|
||||||
|
protected Base $f3;
|
||||||
|
protected DB\SQL $db;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->f3 = Base::instance();
|
||||||
|
$this->db = $this->f3->get('DB');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render(string $view, array $data = []): void
|
||||||
|
{
|
||||||
|
$this->f3->mset($data + [
|
||||||
|
'view' => $view,
|
||||||
|
'currentUser' => $this->currentUser(),
|
||||||
|
'flash' => $this->pullFlash(),
|
||||||
|
'csrfToken' => $this->csrfToken(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo Template::instance()->render('layout.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function currentUser(): ?array
|
||||||
|
{
|
||||||
|
$userId = (int) ($this->f3->get('SESSION.user_id') ?? 0);
|
||||||
|
return $userId > 0 ? (new User($this->db))->findById($userId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireAuth(): void
|
||||||
|
{
|
||||||
|
if ($this->currentUser() !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('error', 'Connecte-toi pour continuer.');
|
||||||
|
$this->f3->reroute($this->f3->alias('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function csrfToken(): string
|
||||||
|
{
|
||||||
|
// Génère un token CSRF et le stocke en session au premier appel.
|
||||||
|
$token = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
||||||
|
if ($token === '') {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->f3->set('SESSION.csrf_token', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function verifyCsrf(): void
|
||||||
|
{
|
||||||
|
$submitted = (string) ($this->f3->get('POST.csrf_token') ?? '');
|
||||||
|
$expected = (string) ($this->f3->get('SESSION.csrf_token') ?? '');
|
||||||
|
|
||||||
|
if ($submitted !== '' && $expected !== '' && hash_equals($expected, $submitted)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->error(400, 'Jeton CSRF invalide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function flash(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$this->f3->set('SESSION.flash', ['type' => $type, 'message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pullFlash(): ?array
|
||||||
|
{
|
||||||
|
$flash = $this->f3->get('SESSION.flash');
|
||||||
|
$this->f3->clear('SESSION.flash');
|
||||||
|
return is_array($flash) ? $flash : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Controllers/DashboardController.php
Normal file
22
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->f3->expire(0);
|
||||||
|
|
||||||
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
|
$result = (new Post($this->db))->paginateList($page, 24);
|
||||||
|
|
||||||
|
$this->render('admin/dashboard.html', [
|
||||||
|
'pageTitle' => 'Tableau de bord',
|
||||||
|
'posts' => $result['posts'],
|
||||||
|
'pagination' => $result,
|
||||||
|
'paginationBase' => $this->f3->alias('dashboard'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Controllers/MediaController.php
Normal file
84
app/Controllers/MediaController.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class MediaController extends BaseController
|
||||||
|
{
|
||||||
|
private const UPLOAD_MAX_BYTES = 10 * 1024 * 1024; // 10 Mo
|
||||||
|
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->f3->expire(0);
|
||||||
|
|
||||||
|
$this->render('admin/media.html', [
|
||||||
|
'pageTitle' => 'Médiathèque',
|
||||||
|
'items' => (new Media($this->db))->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Lire le nom d'origine avant que Web::receive() déplace le fichier.
|
||||||
|
$originalName = (string) ($this->f3->get('FILES.image.name') ?? '');
|
||||||
|
|
||||||
|
$received = Web::instance()->receive(
|
||||||
|
fn(array $file): bool => $file['size'] <= self::UPLOAD_MAX_BYTES,
|
||||||
|
overwrite: false,
|
||||||
|
slug: true
|
||||||
|
);
|
||||||
|
|
||||||
|
// UPLOADS étant absolu (bootstrap.php), les chemins retournés le sont aussi.
|
||||||
|
$accepted = array_keys(array_filter($received));
|
||||||
|
|
||||||
|
if ($accepted === []) {
|
||||||
|
throw new RuntimeException('Choisis une image valide à envoyer (JPG, PNG, WebP ≤ ' . (int)(self::UPLOAD_MAX_BYTES / 1024 / 1024) . ' Mo).');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($accepted as $destPath) {
|
||||||
|
(new Media($this->db))->upload($destPath, $originalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flash('success', 'Image ajoutée.');
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->reroute($this->f3->alias('media_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAlt(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$alt = trim((string) ($this->f3->get('POST.alt') ?? ''));
|
||||||
|
(new Media($this->db))->updateAlt((int) $this->f3->get('PARAMS.id'), $alt);
|
||||||
|
$this->flash('success', 'Texte alternatif mis à jour.');
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->reroute($this->f3->alias('media_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new Media($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
||||||
|
$this->flash('success', 'Image supprimée.');
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->f3->reroute($this->f3->alias('media_index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/Controllers/PostController.php
Normal file
110
app/Controllers/PostController.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class PostController extends BaseController
|
||||||
|
{
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->f3->expire(0);
|
||||||
|
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), Post::emptyForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
$input = $this->postInput();
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new Post($this->db))->create($input);
|
||||||
|
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||||
|
$this->flash('success', 'Article créé.');
|
||||||
|
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->f3->expire(0);
|
||||||
|
$this->renderForm('Nouvel article', $this->f3->alias('post_store'), $input, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->f3->expire(0);
|
||||||
|
|
||||||
|
$post = (new Post($this->db))->findForEdit((int) $this->f3->get('PARAMS.id'));
|
||||||
|
if ($post === null) {
|
||||||
|
$this->f3->error(404, 'Article introuvable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $post['id']]), $post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
|
||||||
|
$id = (int) $this->f3->get('PARAMS.id');
|
||||||
|
$input = $this->postInput() + ['id' => $id];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updated = (new Post($this->db))->updatePost($id, $input);
|
||||||
|
if (!$updated) {
|
||||||
|
$this->f3->error(404, 'Article introuvable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||||
|
$this->flash('success', 'Article mis à jour.');
|
||||||
|
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->f3->expire(0);
|
||||||
|
$this->renderForm('Modifier l\'article', $this->f3->alias('post_update', ['id' => $id]), $input, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
$this->verifyCsrf();
|
||||||
|
(new Post($this->db))->delete((int) $this->f3->get('PARAMS.id'));
|
||||||
|
Cache::instance()->reset('.url'); // invalide le cache des pages publiques
|
||||||
|
$this->flash('success', 'Article supprimé.');
|
||||||
|
$this->f3->reroute($this->f3->alias('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderForm(string $pageTitle, string $formAction, array $post, ?string $error = null): void
|
||||||
|
{
|
||||||
|
$coverPreview = null;
|
||||||
|
if (!empty($post['cover_media_id'])) {
|
||||||
|
$coverPreview = (new Media($this->db))->findById((int) $post['cover_media_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$flash = $error !== null ? ['type' => 'error', 'message' => $error] : null;
|
||||||
|
|
||||||
|
$this->render('admin/post_form.html', [
|
||||||
|
'pageTitle' => $pageTitle,
|
||||||
|
'formAction' => $formAction,
|
||||||
|
'post' => $post,
|
||||||
|
'coverPreview' => $coverPreview,
|
||||||
|
'mediaItems' => (new Media($this->db))->all(),
|
||||||
|
'titleMax' => Post::TITLE_MAX_LENGTH,
|
||||||
|
'excerptMax' => Post::EXCERPT_MAX_LENGTH,
|
||||||
|
'flash' => $flash,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function postInput(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => trim((string) ($this->f3->get('POST.title') ?? '')),
|
||||||
|
'excerpt' => trim((string) ($this->f3->get('POST.excerpt') ?? '')),
|
||||||
|
'cover_media_id' => (string) ($this->f3->get('POST.cover_media_id') ?? ''),
|
||||||
|
'body_markdown' => trim((string) ($this->f3->get('POST.body_markdown') ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Controllers/SiteController.php
Normal file
37
app/Controllers/SiteController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class SiteController extends BaseController
|
||||||
|
{
|
||||||
|
public function home(): void
|
||||||
|
{
|
||||||
|
$this->f3->expire(300); // 5 min — page publique, contenu peu volatile
|
||||||
|
|
||||||
|
$page = max(1, (int) ($this->f3->get('GET.page') ?? 1));
|
||||||
|
$result = (new Post($this->db))->paginateList($page);
|
||||||
|
|
||||||
|
$this->render('site/home.html', [
|
||||||
|
'pageTitle' => 'Accueil',
|
||||||
|
'posts' => $result['posts'],
|
||||||
|
'pagination' => $result,
|
||||||
|
'paginationBase' => $this->f3->alias('home'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
$this->f3->expire(3600); // 1 h — les articles bougent rarement
|
||||||
|
|
||||||
|
$post = (new Post($this->db))->findBySlug((string) $this->f3->get('PARAMS.slug'));
|
||||||
|
if ($post === null) {
|
||||||
|
$this->f3->error(404, 'Article introuvable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('site/post.html', [
|
||||||
|
'pageTitle' => $post['title'],
|
||||||
|
'post' => $post,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Helpers/App.php
Normal file
132
app/Helpers/App.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// ── Core ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function app_root(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_timezone(): string
|
||||||
|
{
|
||||||
|
$timezone = trim((string) Base::instance()->get('app.timezone'));
|
||||||
|
return ($timezone !== '' && in_array($timezone, DateTimeZone::listIdentifiers(), true)) ? $timezone : 'UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_now(): string
|
||||||
|
{
|
||||||
|
return gmdate('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_is_prod(): bool
|
||||||
|
{
|
||||||
|
return Base::instance()->get('app.env') === 'prod';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fichiers et chemins ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function app_ensure_dir(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
mkdir($path, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_db_path(): string
|
||||||
|
{
|
||||||
|
return app_root() . '/db/app.sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_logs_dir(): string
|
||||||
|
{
|
||||||
|
return app_root() . '/logs';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_public_media_dir(): string
|
||||||
|
{
|
||||||
|
return app_root() . '/public/uploads/media';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_media_url(string $fileName): string
|
||||||
|
{
|
||||||
|
return rtrim((string) Base::instance()->get('BASE'), '/') . '/uploads/media/' . rawurlencode($fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Texte ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function app_slugify(string $value): string
|
||||||
|
{
|
||||||
|
$slug = Web::instance()->slug(trim($value));
|
||||||
|
return $slug !== '' ? $slug : 'article';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_unique_slug(string $value, callable $exists): string
|
||||||
|
{
|
||||||
|
$base = app_slugify($value);
|
||||||
|
if (!$exists($base)) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 2; $i <= 1000; $i++) {
|
||||||
|
$candidate = $base . '-' . $i;
|
||||||
|
if (!$exists($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Impossible de générer un slug unique.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_format_datetime_fr(string $value): string
|
||||||
|
{
|
||||||
|
static $utc, $formatter;
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$utc ??= new DateTimeZone('UTC');
|
||||||
|
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $utc);
|
||||||
|
if (!$date instanceof DateTimeImmutable) {
|
||||||
|
$date = new DateTimeImmutable($value, $utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
|
||||||
|
|
||||||
|
if (class_exists('IntlDateFormatter')) {
|
||||||
|
$formatter ??= new IntlDateFormatter(
|
||||||
|
'fr_FR',
|
||||||
|
IntlDateFormatter::LONG,
|
||||||
|
IntlDateFormatter::SHORT,
|
||||||
|
date_default_timezone_get(),
|
||||||
|
IntlDateFormatter::GREGORIAN,
|
||||||
|
"d MMMM yyyy 'à' HH:mm"
|
||||||
|
);
|
||||||
|
|
||||||
|
$formatted = $formatter->format($date);
|
||||||
|
if (is_string($formatted) && $formatted !== '') {
|
||||||
|
return $formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [
|
||||||
|
1 => 'janvier', 2 => 'février', 3 => 'mars', 4 => 'avril',
|
||||||
|
5 => 'mai', 6 => 'juin', 7 => 'juillet', 8 => 'août',
|
||||||
|
9 => 'septembre', 10 => 'octobre', 11 => 'novembre', 12 => 'décembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%d %s %d à %s',
|
||||||
|
(int) $date->format('j'),
|
||||||
|
$months[(int) $date->format('n')] ?? $date->format('F'),
|
||||||
|
(int) $date->format('Y'),
|
||||||
|
$date->format('H:i')
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Helpers/Error.php
Normal file
162
app/Helpers/Error.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function app_error_meta(int $code): array
|
||||||
|
{
|
||||||
|
return match ($code) {
|
||||||
|
400 => ['title' => 'Requête invalide', 'message' => 'La requête envoyée est invalide.'],
|
||||||
|
403 => ['title' => 'Accès refusé', 'message' => 'Tu n\u2019as pas accès à cette ressource.'],
|
||||||
|
404 => ['title' => 'Page introuvable', 'message' => 'La page demandée est introuvable.'],
|
||||||
|
default => ['title' => 'Erreur serveur', 'message' => 'Une erreur est survenue.'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_bootstrap_logging(): void
|
||||||
|
{
|
||||||
|
$dir = rtrim((string) Base::instance()->get('LOGS'), '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
app_ensure_dir($dir);
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
ini_set('error_log', $dir . 'php-error.log');
|
||||||
|
ini_set('display_errors', app_is_prod() ? '0' : '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_request_summary(): string
|
||||||
|
{
|
||||||
|
$f3 = Base::instance();
|
||||||
|
return sprintf(
|
||||||
|
'request=%s %s ip=%s',
|
||||||
|
(string) ($f3->get('VERB') ?? 'CLI'),
|
||||||
|
(string) ($f3->get('URI') ?? '/'),
|
||||||
|
(string) ($f3->get('IP') ?? '0.0.0.0')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_write_log(string $fileName, string $line): void
|
||||||
|
{
|
||||||
|
(new Log($fileName))->write($line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_log_error(int $code, string $status, string $text, ?Throwable $exception = null): void
|
||||||
|
{
|
||||||
|
if ($code === 404) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$level = $code >= 500 ? 'error' : ($code >= 400 ? 'warning' : 'info');
|
||||||
|
$parts = [
|
||||||
|
sprintf('level=%s code=%d status="%s"', $level, $code, $status),
|
||||||
|
app_request_summary(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($text !== '') {
|
||||||
|
$parts[] = 'message="' . str_replace(["\n", '"'], ['\\n', '\\"'], $text) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exception !== null) {
|
||||||
|
$parts[] = sprintf('exception="%s" file="%s:%d"', $exception::class, $exception->getFile(), $exception->getLine());
|
||||||
|
}
|
||||||
|
|
||||||
|
app_write_log('app.log', implode(' | ', $parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_render_error_json(int $code): void
|
||||||
|
{
|
||||||
|
$f3 = Base::instance();
|
||||||
|
$meta = app_error_meta($code);
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
$f3->status($code);
|
||||||
|
$f3->expire(0);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
|
echo json_encode(
|
||||||
|
['error' => ['code' => $code, 'title' => $meta['title'], 'message' => $meta['message']]],
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_render_error_fallback(int $code): void
|
||||||
|
{
|
||||||
|
$f3 = Base::instance();
|
||||||
|
$meta = app_error_meta($code);
|
||||||
|
$base = rtrim((string) $f3->get('BASE'), '/');
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
$f3->status($code);
|
||||||
|
$f3->expire(0);
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = htmlspecialchars((string) $meta['title'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$message = htmlspecialchars((string) $meta['message'], ENT_QUOTES, 'UTF-8');
|
||||||
|
$href = htmlspecialchars($base . '/', ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
echo '<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $title . '</title></head><body><main><h1>' . $title . '</h1><p>' . $message . '</p><p><a href="' . $href . '">Retour à l\'accueil</a></p></main></body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_bootstrap_errors(Base $f3): void
|
||||||
|
{
|
||||||
|
if (app_is_prod()) {
|
||||||
|
register_shutdown_function(function (): void {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
|
||||||
|
if (!in_array($error['type'] ?? 0, $fatalTypes, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_render_error_fallback(500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$f3->set('ONERROR', function (Base $f3): void {
|
||||||
|
$code = (int) ($f3->get('ERROR.code') ?? 500);
|
||||||
|
$status = (string) ($f3->get('ERROR.status') ?? 'Internal Server Error');
|
||||||
|
$text = (string) ($f3->get('ERROR.text') ?? '');
|
||||||
|
|
||||||
|
if (!app_is_prod() && (int) $f3->get('DEBUG') > 0) {
|
||||||
|
$f3->status($code > 0 ? $code : 500);
|
||||||
|
echo $text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $code > 0 ? $code : 500;
|
||||||
|
app_log_error($code, $status, $text);
|
||||||
|
|
||||||
|
if ($f3->get('AJAX')) {
|
||||||
|
app_render_error_json($code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$f3->expire(0);
|
||||||
|
$f3->status($code);
|
||||||
|
|
||||||
|
$meta = app_error_meta($code);
|
||||||
|
$f3->mset([
|
||||||
|
'errorCode' => $code,
|
||||||
|
'errorTitle' => $meta['title'],
|
||||||
|
'errorMessage' => $meta['message'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
echo Template::instance()->render('errors/error.html');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
app_log_error(500, 'Internal Server Error', 'Error template rendering failed.', $exception);
|
||||||
|
app_render_error_fallback($code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
155
app/Models/Media.php
Normal file
155
app/Models/Media.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Media extends DB\SQL\Mapper
|
||||||
|
{
|
||||||
|
public function __construct(DB\SQL $db)
|
||||||
|
{
|
||||||
|
parent::__construct($db, 'media');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootstrap(DB\SQL $db): void
|
||||||
|
{
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_name TEXT NOT NULL UNIQUE,
|
||||||
|
alt TEXT NOT NULL DEFAULT \'\',
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
fn (self $m): array => $this->decorate($m->cast()),
|
||||||
|
$this->find(null, ['order' => 'created_at DESC, id DESC']) ?: []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
return $this->dry() ? null : $this->decorate($this->cast());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByFileName(string $fileName): ?array
|
||||||
|
{
|
||||||
|
$this->load(['file_name = ?', $fileName]);
|
||||||
|
return $this->dry() ? null : $this->decorate($this->cast());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine
|
||||||
|
// pour dériver un texte alternatif lisible.
|
||||||
|
public function upload(string $srcPath, string $originalName = ''): int
|
||||||
|
{
|
||||||
|
// Image::dump() gère le chargement, la transparence et la compression.
|
||||||
|
// $path='' indique à F3 que le chemin est absolu.
|
||||||
|
try {
|
||||||
|
$img = new Image($srcPath, false, '');
|
||||||
|
} catch (Throwable) {
|
||||||
|
throw new RuntimeException('Fichier image invalide ou format non supporté (JPG, PNG, WebP).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $img->dump('png', 9); // sans perte, compression maximale
|
||||||
|
|
||||||
|
// Supprimer le fichier intermédiaire déposé par Web::receive().
|
||||||
|
@unlink($srcPath);
|
||||||
|
|
||||||
|
$fileName = bin2hex(random_bytes(16)) . '.png';
|
||||||
|
$target = app_public_media_dir() . '/' . $fileName;
|
||||||
|
|
||||||
|
if (!Base::instance()->write($target, $data)) {
|
||||||
|
throw new RuntimeException('Impossible d\'enregistrer cette image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
$this->file_name = $fileName;
|
||||||
|
$this->alt = $originalName !== '' ? self::altFromFilename($originalName) : '';
|
||||||
|
$this->width = $img->width();
|
||||||
|
$this->height = $img->height();
|
||||||
|
$this->created_at = app_now();
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return (int) $this->get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateAlt(int $id, string $alt): void
|
||||||
|
{
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
if ($this->dry()) {
|
||||||
|
throw new RuntimeException('Image introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->alt = trim($alt);
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$item = $this->findById($id);
|
||||||
|
if ($item === null) {
|
||||||
|
throw new RuntimeException('Image introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isUsed($item)) {
|
||||||
|
throw new RuntimeException('Cette image est encore utilisée par un article.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = app_public_media_dir() . '/' . $item['file_name'];
|
||||||
|
|
||||||
|
$this->db->begin();
|
||||||
|
try {
|
||||||
|
$this->erase();
|
||||||
|
if (is_file($path) && !unlink($path)) {
|
||||||
|
throw new RuntimeException('Impossible de supprimer le fichier image.');
|
||||||
|
}
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->db->rollback();
|
||||||
|
throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dérive un texte alternatif lisible depuis le nom de fichier d'origine.
|
||||||
|
private static function altFromFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||||
|
$name = trim((string) preg_replace('/[-_]+/', ' ', $name));
|
||||||
|
// mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule.
|
||||||
|
return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une seule requête SQL pour les deux cas d'utilisation (couverture et body).
|
||||||
|
private function isUsed(array $item): bool
|
||||||
|
{
|
||||||
|
return $this->db->exec(
|
||||||
|
'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1',
|
||||||
|
[$item['id'], '%media:' . $item['file_name'] . '%']
|
||||||
|
) !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decorate(array $row): array
|
||||||
|
{
|
||||||
|
$alt = (string) $row['alt'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'file_name' => (string) $row['file_name'],
|
||||||
|
'alt' => $alt,
|
||||||
|
'width' => (int) $row['width'],
|
||||||
|
'height' => (int) $row['height'],
|
||||||
|
'created_at' => (string) $row['created_at'],
|
||||||
|
'created_at_label' => app_format_datetime_fr((string) $row['created_at']),
|
||||||
|
'url' => app_media_url((string) $row['file_name']),
|
||||||
|
'markdown' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
223
app/Models/Post.php
Normal file
223
app/Models/Post.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Post extends DB\SQL\Mapper
|
||||||
|
{
|
||||||
|
public const TITLE_MAX_LENGTH = 120;
|
||||||
|
public const EXCERPT_MAX_LENGTH = 240;
|
||||||
|
|
||||||
|
public function __construct(DB\SQL $db)
|
||||||
|
{
|
||||||
|
parent::__construct($db, 'posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootstrap(DB\SQL $db): void
|
||||||
|
{
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
excerpt TEXT NOT NULL,
|
||||||
|
body_markdown TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
cover_media_id INTEGER DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function emptyForm(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => '',
|
||||||
|
'excerpt' => '',
|
||||||
|
'cover_media_id' => '',
|
||||||
|
'body_markdown' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginateList(int $page = 1, int $perPage = 12): array
|
||||||
|
{
|
||||||
|
$result = $this->paginate(
|
||||||
|
max(0, $page - 1),
|
||||||
|
$perPage,
|
||||||
|
null,
|
||||||
|
['order' => 'created_at DESC, id DESC']
|
||||||
|
);
|
||||||
|
|
||||||
|
$posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']);
|
||||||
|
$covers = $this->loadCovers($posts);
|
||||||
|
|
||||||
|
foreach ($posts as &$post) {
|
||||||
|
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||||
|
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'posts' => $posts,
|
||||||
|
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||||||
|
'pages' => $result['count'] ?: 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findBySlug(string $slug): ?array
|
||||||
|
{
|
||||||
|
$this->load(['slug = ?', $slug]);
|
||||||
|
if ($this->dry()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = $this->summaryRow($this->cast());
|
||||||
|
$covers = $this->loadCovers([$post]);
|
||||||
|
$cover = $covers[$post['cover_media_id']] ?? null;
|
||||||
|
$post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : '';
|
||||||
|
$post['body_html'] = (string) $this->body_html;
|
||||||
|
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findForEdit(int $id): ?array
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
if ($this->dry()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $this->get('id'),
|
||||||
|
'title' => (string) $this->title,
|
||||||
|
'excerpt' => (string) $this->excerpt,
|
||||||
|
'body_markdown' => (string) $this->body_markdown,
|
||||||
|
'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $input): int
|
||||||
|
{
|
||||||
|
$payload = $this->payload($input);
|
||||||
|
$slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate));
|
||||||
|
$now = app_now();
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
$this->copyfrom($payload + [
|
||||||
|
'slug' => $slug,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return (int) $this->get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePost(int $id, array $input): bool
|
||||||
|
{
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
if ($this->dry()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->payload($input);
|
||||||
|
$this->copyfrom($payload + ['updated_at' => app_now()]);
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
if (!$this->dry()) {
|
||||||
|
$this->erase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payload(array $input): array
|
||||||
|
{
|
||||||
|
$title = trim((string) ($input['title'] ?? ''));
|
||||||
|
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||||||
|
$bodyMarkdown = trim((string) ($input['body_markdown'] ?? ''));
|
||||||
|
$coverMediaId = trim((string) ($input['cover_media_id'] ?? ''));
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
throw new RuntimeException('Ajoute un titre.');
|
||||||
|
}
|
||||||
|
if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
|
||||||
|
throw new RuntimeException('Le titre est trop long.');
|
||||||
|
}
|
||||||
|
if ($excerpt === '') {
|
||||||
|
throw new RuntimeException('Ajoute un extrait.');
|
||||||
|
}
|
||||||
|
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
|
||||||
|
throw new RuntimeException("L'extrait est trop long.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$media = new Media($this->db);
|
||||||
|
|
||||||
|
$coverId = null;
|
||||||
|
if ($coverMediaId !== '') {
|
||||||
|
$coverId = (int) $coverMediaId;
|
||||||
|
if ($media->findById($coverId) === null) {
|
||||||
|
throw new RuntimeException('Image de couverture introuvable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bodyHtml = MarkdownService::compile($bodyMarkdown, $media);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'excerpt' => $excerpt,
|
||||||
|
'body_markdown' => $bodyMarkdown,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
'cover_media_id' => $coverId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugExists(string $slug): bool
|
||||||
|
{
|
||||||
|
return $this->count(['slug = ?', $slug]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function summaryRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'title' => (string) $row['title'],
|
||||||
|
'slug' => (string) $row['slug'],
|
||||||
|
'excerpt' => (string) $row['excerpt'],
|
||||||
|
'cover_media_id' => (int) ($row['cover_media_id'] ?? 0),
|
||||||
|
'created_at' => (string) $row['created_at'],
|
||||||
|
'created_at_label' => app_format_datetime_fr((string) $row['created_at']),
|
||||||
|
'updated_at' => (string) $row['updated_at'],
|
||||||
|
'updated_at_label' => app_format_datetime_fr((string) $row['updated_at']),
|
||||||
|
'has_updated_at' => (string) $row['updated_at'] !== (string) $row['created_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCovers(array $posts): array
|
||||||
|
{
|
||||||
|
$ids = array_filter(array_unique(array_column($posts, 'cover_media_id')));
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$rows = $this->db->exec(
|
||||||
|
"SELECT id, file_name FROM media WHERE id IN ($placeholders)",
|
||||||
|
array_values($ids)
|
||||||
|
);
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int) $row['id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Models/User.php
Normal file
67
app/Models/User.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class User extends DB\SQL\Mapper
|
||||||
|
{
|
||||||
|
public function __construct(DB\SQL $db)
|
||||||
|
{
|
||||||
|
parent::__construct($db, 'users');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootstrap(DB\SQL $db): void
|
||||||
|
{
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->load(['id = ?', $id]);
|
||||||
|
if ($this->dry()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->cast();
|
||||||
|
unset($data['password_hash']);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUsername(string $username): ?array
|
||||||
|
{
|
||||||
|
$this->load(['username = ?', $username]);
|
||||||
|
return $this->dry() ? null : $this->cast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $username, string $password): int
|
||||||
|
{
|
||||||
|
$username = trim($username);
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
throw new RuntimeException('Nom d’utilisateur et mot de passe obligatoires.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($password) < 10) {
|
||||||
|
throw new RuntimeException('Le mot de passe doit contenir au moins 10 caractères.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->findByUsername($username) !== null) {
|
||||||
|
throw new RuntimeException('Cet utilisateur existe déjà.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
$this->username = $username;
|
||||||
|
$this->password_hash = password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
$this->created_at = app_now();
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return (int) $this->get('id');
|
||||||
|
}
|
||||||
|
}
|
||||||
192
app/Services/MarkdownService.php
Normal file
192
app/Services/MarkdownService.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class MarkdownService
|
||||||
|
{
|
||||||
|
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 ALLOWED_ATTRS = [
|
||||||
|
'a' => ['href', 'title', 'rel', 'target'],
|
||||||
|
'img' => ['src', 'alt', 'title', 'loading', 'decoding'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function compile(string $markdown, Media $media): string
|
||||||
|
{
|
||||||
|
$markdown = trim($markdown);
|
||||||
|
if ($markdown === '') {
|
||||||
|
throw new RuntimeException('Ajoute du contenu avant de publier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = self::normalizeMarkdown($markdown);
|
||||||
|
$html = Markdown::instance()->convert($markdown);
|
||||||
|
$html = self::sanitizeAndResolve($html, $media);
|
||||||
|
|
||||||
|
if (trim(strip_tags($html)) === '' && !preg_match('/<(img|video|audio|figure)[\s>]/i', $html)) {
|
||||||
|
$fallback = nl2br(htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||||
|
$html = '<p>' . str_replace('<br />', '</p><p>', $fallback) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passe DOM unique : sanitise les balises/attributs et résout les références media:.
|
||||||
|
private static function sanitizeAndResolve(string $html, Media $media): string
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$dom->loadHTML('<?xml encoding="UTF-8"><body>' . $html . '</body>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
$body = $dom->getElementsByTagName('body')->item(0);
|
||||||
|
if (!$body instanceof DOMElement) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
self::processNode($body, $media);
|
||||||
|
|
||||||
|
$out = '';
|
||||||
|
foreach ($body->childNodes as $child) {
|
||||||
|
$out .= $dom->saveHTML($child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function processNode(DOMNode $parent, Media $media): void
|
||||||
|
{
|
||||||
|
for ($i = $parent->childNodes->length - 1; $i >= 0; $i--) {
|
||||||
|
$child = $parent->childNodes->item($i);
|
||||||
|
if ($child === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($child instanceof DOMComment) {
|
||||||
|
$parent->removeChild($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($child instanceof DOMText) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$child instanceof DOMElement) {
|
||||||
|
$parent->removeChild($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($child->tagName, self::ALLOWED_TAGS, true)) {
|
||||||
|
self::unwrap($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::sanitizeAttributes($child, $media);
|
||||||
|
|
||||||
|
// img may have been removed by sanitizeAttributes
|
||||||
|
if ($child->parentNode !== null) {
|
||||||
|
self::processNode($child, $media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function sanitizeAttributes(DOMElement $element, Media $media): void
|
||||||
|
{
|
||||||
|
$allowed = self::ALLOWED_ATTRS[$element->tagName] ?? [];
|
||||||
|
$toRemove = [];
|
||||||
|
foreach ($element->attributes as $attribute) {
|
||||||
|
if (!in_array($attribute->name, $allowed, true)) {
|
||||||
|
$toRemove[] = $attribute->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($toRemove as $name) {
|
||||||
|
$element->removeAttribute($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($element->tagName === 'a') {
|
||||||
|
$href = trim($element->getAttribute('href'));
|
||||||
|
if ($href === '' || !preg_match('~^(https?:|mailto:|tel:|/)~i', $href)) {
|
||||||
|
$element->removeAttribute('href');
|
||||||
|
} else {
|
||||||
|
$element->setAttribute('rel', 'noopener noreferrer');
|
||||||
|
if (preg_match('~^https?://~i', $href)) {
|
||||||
|
$element->setAttribute('target', '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($element->tagName === 'img') {
|
||||||
|
$src = trim($element->getAttribute('src'));
|
||||||
|
if ($src === '' || !str_starts_with($src, 'media:')) {
|
||||||
|
$element->parentNode?->removeChild($element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = substr($src, 6);
|
||||||
|
$item = $media->findByFileName($fileName);
|
||||||
|
if ($item === null) {
|
||||||
|
throw new RuntimeException('Une image utilisée dans le Markdown est introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$element->setAttribute('src', (string) $item['url']);
|
||||||
|
$element->setAttribute('loading', 'lazy');
|
||||||
|
$element->setAttribute('decoding', 'async');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeMarkdown(string $markdown): string
|
||||||
|
{
|
||||||
|
$markdown = str_replace(["\r\n", "\r"], "\n", $markdown);
|
||||||
|
$lines = explode("\n", $markdown);
|
||||||
|
$normalized = [];
|
||||||
|
$inFence = false;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^\s*(```|~~~)/', $line) === 1) {
|
||||||
|
$inFence = !$inFence;
|
||||||
|
$normalized[] = $line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inFence) {
|
||||||
|
$normalized[] = $line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isBlank = trim($line) === '';
|
||||||
|
$isListItem = preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $line) === 1;
|
||||||
|
$previous = $normalized[count($normalized) - 1] ?? null;
|
||||||
|
$previousIsBlank = $previous === null || trim($previous) === '';
|
||||||
|
$previousIsListItem = $previous !== null && preg_match('/^\s*(?:[-+*]|\d+\.)\s+/', $previous) === 1;
|
||||||
|
|
||||||
|
if ($isListItem && !$previousIsBlank && !$previousIsListItem) {
|
||||||
|
$normalized[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isBlank && !$isListItem && $previousIsListItem) {
|
||||||
|
$normalized[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim(implode("\n", $normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function unwrap(DOMElement $element): void
|
||||||
|
{
|
||||||
|
$parent = $element->parentNode;
|
||||||
|
if ($parent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ($element->firstChild !== null) {
|
||||||
|
$parent->insertBefore($element->firstChild, $element);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent->removeChild($element);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Views/admin/dashboard.html
Normal file
27
app/Views/admin/dashboard.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<section class="stack-lg" aria-labelledby="dashboard-title">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 class="page-title" id="dashboard-title">Tableau de bord</h1>
|
||||||
|
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="button" href="{{ @BASE }}/dashboard/posts/create">Nouvel article</a>
|
||||||
|
<a class="button button--ghost" href="{{ @BASE }}/dashboard/media">Médiathèque</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<check if="{{ @posts }}">
|
||||||
|
<true>
|
||||||
|
<div class="card-grid">
|
||||||
|
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||||
|
<include href="partials/post_card_admin.html" />
|
||||||
|
</repeat>
|
||||||
|
</div>
|
||||||
|
<include href="partials/pagination.html" />
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<section class="empty-state" aria-labelledby="dashboard-empty-title">
|
||||||
|
<h2 class="card-title" id="dashboard-empty-title">Aucun article</h2>
|
||||||
|
<p>Commence par créer un premier article.</p>
|
||||||
|
</section>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</section>
|
||||||
35
app/Views/admin/media.html
Normal file
35
app/Views/admin/media.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<section class="stack-lg" aria-labelledby="media-title">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 class="page-title" id="media-title">Médiathèque</h1>
|
||||||
|
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="panel stack" method="post" action="{{ @BASE }}/dashboard/media" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Nouvelle image</span>
|
||||||
|
<input class="control" type="file" name="image" accept="image/jpeg,image/png,image/webp" required>
|
||||||
|
<span class="field-help">Formats acceptés : JPG, PNG, WebP.</span>
|
||||||
|
</label>
|
||||||
|
<button class="button" type="submit">Envoyer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<check if="{{ @items }}">
|
||||||
|
<true>
|
||||||
|
<div class="card-grid">
|
||||||
|
<repeat group="{{ @items }}" value="{{ @item }}">
|
||||||
|
<include href="partials/media_card.html" />
|
||||||
|
</repeat>
|
||||||
|
</div>
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<section class="empty-state" aria-labelledby="media-empty-title">
|
||||||
|
<h2 class="card-title" id="media-empty-title">Aucune image</h2>
|
||||||
|
<p>Ajoute ta première image.</p>
|
||||||
|
</section>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</section>
|
||||||
107
app/Views/admin/post_form.html
Normal file
107
app/Views/admin/post_form.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<section class="stack-lg" aria-labelledby="post-form-title">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 class="page-title" id="post-form-title">{{ @pageTitle }}</h1>
|
||||||
|
|
||||||
|
<div class="page-actions">
|
||||||
|
<a class="button button--ghost" href="{{ @BASE }}/dashboard">Retour</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="editor-layout" data-editor-layout>
|
||||||
|
<form class="panel stack editor-form" method="post" action="{{ @formAction }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<input type="hidden" name="cover_media_id" value="{{ @post.cover_media_id }}" data-cover-input>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Titre</span>
|
||||||
|
<input class="control" type="text" name="title" value="{{ @post.title }}" maxlength="{{ @titleMax }}" required data-char-count>
|
||||||
|
<span class="char-counter"><span data-char-count-value>0</span> / {{ @titleMax }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Extrait</span>
|
||||||
|
<textarea class="control" name="excerpt" rows="3" maxlength="{{ @excerptMax }}" required data-char-count>{{ @post.excerpt }}</textarea>
|
||||||
|
<span class="char-counter"><span data-char-count-value>0</span> / {{ @excerptMax }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section class="field cover-field">
|
||||||
|
<div class="field-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="field-label">Image de couverture</h2>
|
||||||
|
<p class="field-help">Choisis une image si tu veux une couverture.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cover-picker">
|
||||||
|
<check if="{{ @coverPreview }}">
|
||||||
|
<true>
|
||||||
|
<img class="media-frame media-frame--large cover-preview" data-cover-preview src="{{ @coverPreview.url }}" alt="">
|
||||||
|
<div class="media-frame media-frame--large media-frame--placeholder is-hidden" data-cover-placeholder>Aucune image</div>
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<div class="media-frame media-frame--large media-frame--placeholder" data-cover-placeholder>Aucune image</div>
|
||||||
|
<img class="media-frame media-frame--large cover-preview is-hidden" data-cover-preview alt="Aperçu couverture">
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="button button--ghost" type="button" data-media-picker-open="cover">Choisir une image</button>
|
||||||
|
<button class="button button--ghost" type="button" data-cover-clear {{ @post.cover_media_id ? '' : 'disabled' }}>Retirer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="field">
|
||||||
|
<div class="field-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="field-label">Contenu</h2>
|
||||||
|
<p class="field-help">Markdown simple, avec insertion d’image au curseur.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="tool-button" type="button" data-md-action="italic"><em>Italique</em></button>
|
||||||
|
<button class="tool-button" type="button" data-md-action="heading">Titre</button>
|
||||||
|
<button class="tool-button" type="button" data-md-action="list">Liste</button>
|
||||||
|
<button class="tool-button" type="button" data-md-action="quote">Citation</button>
|
||||||
|
<button class="tool-button" type="button" data-md-action="link">Lien</button>
|
||||||
|
<button class="tool-button" type="button" data-md-action="code">Code</button>
|
||||||
|
<button class="tool-button" type="button" data-media-picker-open="markdown">Image</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea class="control editor-textarea" name="body_markdown" rows="18" required data-markdown-editor>{{ @post.body_markdown }}</textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="button" type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="media-picker is-hidden" data-media-picker>
|
||||||
|
<div class="media-picker__head">
|
||||||
|
<div>
|
||||||
|
<strong data-media-picker-title>Choisir une image</strong>
|
||||||
|
<p class="field-help" data-media-picker-help>Choisis une image de la médiathèque.</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-id="{{ @item.id }}" data-media-url="{{ @item.url }}" data-media-markdown="{{ @item.markdown }}">
|
||||||
|
<img class="media-frame media-frame--square" src="{{ @item.url }}" alt="">
|
||||||
|
</button>
|
||||||
|
</repeat>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
21
app/Views/auth/login.html
Normal file
21
app/Views/auth/login.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<section class="auth-shell panel stack" aria-labelledby="login-title">
|
||||||
|
<header class="page-header page-header--compact">
|
||||||
|
<h1 class="page-title" id="login-title">Connexion</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="stack" method="post" action="{{ @BASE }}/login">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Nom d’utilisateur</span>
|
||||||
|
<input class="control" type="text" name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Mot de passe</span>
|
||||||
|
<input class="control" type="password" name="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button" type="submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
23
app/Views/errors/error.html
Normal file
23
app/Views/errors/error.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ @errorTitle ?: 'Erreur' }}</title>
|
||||||
|
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page error-page">
|
||||||
|
<div class="container">
|
||||||
|
<section class="error-card">
|
||||||
|
<p class="error-page__code">Erreur {{ @errorCode ?: 500 }}</p>
|
||||||
|
<h1 class="error-page__title">{{ @errorTitle ?: 'Erreur' }}</h1>
|
||||||
|
<p class="error-page__message">{{ @errorMessage ?: 'Une erreur est survenue.' }}</p>
|
||||||
|
<p class="error-page__hint">Vérifie l’adresse ou reviens à l’accueil.</p>
|
||||||
|
<p class="error-page__actions"><a class="button" href="{{ @BASE }}/">Retour à l’accueil</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
app/Views/layout.html
Normal file
24
app/Views/layout.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ @pageTitle ? @pageTitle . ' · ' . @app.name : @app.name }}</title>
|
||||||
|
<meta name="description" content="{{ @app.tagline }}">
|
||||||
|
<link rel="icon" href="{{ @BASE }}/assets/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="{{ @BASE }}/min/app.css">
|
||||||
|
<script defer src="{{ @BASE }}/min/app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<include href="partials/site_navigation.html" />
|
||||||
|
|
||||||
|
<main class="page" id="main-content">
|
||||||
|
<div class="container">
|
||||||
|
<check if="{{ @flash }}">
|
||||||
|
<div class="flash flash--{{ @flash.type }}" role="status">{{ @flash.message }}</div>
|
||||||
|
</check>
|
||||||
|
<include href="{{ @view }}" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
app/Views/partials/media_card.html
Normal file
23
app/Views/partials/media_card.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<article class="card article-card">
|
||||||
|
<img class="media-frame" src="{{ @item.url }}" alt="{{ @item.alt }}">
|
||||||
|
<div class="card-body article-card__body">
|
||||||
|
<p class="meta-text">{{ @item.width }} × {{ @item.height }}<br>{{ @item.created_at_label }}</p>
|
||||||
|
|
||||||
|
<form class="stack" method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/alt">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<label class="field">
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
<button class="button button--ghost button--small" type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="button button--ghost" type="button" data-copy-text="{{ @item.markdown }}" data-markdown-template="">Copier le Markdown</button>
|
||||||
|
<form method="post" action="{{ @BASE }}/dashboard/media/{{ @item.id }}/delete" data-confirm="Supprimer cette image ?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<button class="button button--danger" type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
20
app/Views/partials/nav_items.html
Normal file
20
app/Views/partials/nav_items.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<ul class="nav-items">
|
||||||
|
<check if="{{ @currentUser }}">
|
||||||
|
<true>
|
||||||
|
<li class="nav-items__item">
|
||||||
|
<a class="nav-items__link" href="{{ @BASE }}/dashboard">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-items__item">
|
||||||
|
<form class="nav-items__form" method="post" action="{{ @BASE }}/logout">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<button class="nav-items__button" type="submit">Déconnexion</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<li class="nav-items__item">
|
||||||
|
<a class="nav-items__link" href="{{ @BASE }}/login">Connexion</a>
|
||||||
|
</li>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</ul>
|
||||||
25
app/Views/partials/pagination.html
Normal file
25
app/Views/partials/pagination.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<check if="{{ @pagination.pages > 1 }}">
|
||||||
|
<true>
|
||||||
|
<nav class="pagination" aria-label="Pagination">
|
||||||
|
<check if="{{ @pagination.page > 1 }}">
|
||||||
|
<true>
|
||||||
|
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page - 1 }}">Précédent</a>
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<span class="button button--ghost pagination__disabled">Précédent</span>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<span class="pagination__info">Page {{ @pagination.page }} sur {{ @pagination.pages }}</span>
|
||||||
|
|
||||||
|
<check if="{{ @pagination.page < @pagination.pages }}">
|
||||||
|
<true>
|
||||||
|
<a class="button button--ghost" href="{{ @paginationBase }}?page={{ @pagination.page + 1 }}">Suivant</a>
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<span class="button button--ghost pagination__disabled">Suivant</span>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</nav>
|
||||||
|
</true>
|
||||||
|
</check>
|
||||||
20
app/Views/partials/post_card.html
Normal file
20
app/Views/partials/post_card.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<article class="card article-card">
|
||||||
|
<a class="card-media-link" href="{{ @BASE }}/posts/{{ @post.slug }}">
|
||||||
|
<check if="{{ @post.cover_url }}">
|
||||||
|
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||||
|
<false>
|
||||||
|
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</a>
|
||||||
|
<div class="card-body article-card__body">
|
||||||
|
<h2 class="card-title">{{ @post.title }}</h2>
|
||||||
|
<p class="meta-text">
|
||||||
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||||
|
<check if="{{ @post.has_updated_at }}">
|
||||||
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||||
|
</check>
|
||||||
|
</p>
|
||||||
|
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
29
app/Views/partials/post_card_admin.html
Normal file
29
app/Views/partials/post_card_admin.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<article class="card article-card">
|
||||||
|
<a class="card-media-link" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">
|
||||||
|
<check if="{{ @post.cover_url }}">
|
||||||
|
<true><img class="media-frame" src="{{ @post.cover_url }}" alt="{{ @post.title }}"></true>
|
||||||
|
<false>
|
||||||
|
<div class="media-frame media-frame--placeholder">Aucune image</div>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</a>
|
||||||
|
<div class="card-body article-card__body">
|
||||||
|
<h2 class="card-title">{{ @post.title }}</h2>
|
||||||
|
<p class="meta-text">
|
||||||
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||||
|
<check if="{{ @post.has_updated_at }}">
|
||||||
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||||
|
</check>
|
||||||
|
</p>
|
||||||
|
<p class="card-summary">{{ @post.excerpt }}</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a class="button button--ghost" href="{{ @BASE }}/posts/{{ @post.slug }}">Voir</a>
|
||||||
|
<a class="button button--ghost" href="{{ @BASE }}/dashboard/posts/{{ @post.id }}/edit">Modifier</a>
|
||||||
|
<form method="post" action="{{ @BASE }}/dashboard/posts/{{ @post.id }}/delete"
|
||||||
|
data-confirm="Supprimer cet article ?">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ @csrfToken }}">
|
||||||
|
<button class="button button--danger" type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
1
app/Views/partials/site_brand.html
Normal file
1
app/Views/partials/site_brand.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<a class="site-brand__title" href="{{ @BASE }}/">{{ @app.name }}</a>
|
||||||
44
app/Views/partials/site_navigation.html
Normal file
44
app/Views/partials/site_navigation.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<input class="nav-toggle" type="checkbox" id="nav-toggle" aria-hidden="true">
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container site-header__inner">
|
||||||
|
<label class="nav-toggle-button" for="nav-toggle">
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="site-brand site-brand--header">
|
||||||
|
<include href="partials/site_brand.html" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav nav--desktop" aria-label="Navigation principale">
|
||||||
|
<include href="partials/nav_items.html" />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<span class="site-header__spacer" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mobile-menu">
|
||||||
|
<label class="mobile-menu__backdrop" for="nav-toggle" aria-hidden="true"></label>
|
||||||
|
|
||||||
|
<div class="mobile-menu__panel">
|
||||||
|
<header class="mobile-menu__header">
|
||||||
|
<div class="site-brand site-brand--menu">
|
||||||
|
<include href="partials/site_brand.html" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mobile-menu__close" for="nav-toggle">
|
||||||
|
<span class="sr-only">Fermer le menu</span>
|
||||||
|
<span class="mobile-menu__close-line"></span>
|
||||||
|
<span class="mobile-menu__close-line"></span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="mobile-menu__nav" aria-label="Navigation principale mobile">
|
||||||
|
<include href="partials/nav_items.html" />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
22
app/Views/site/home.html
Normal file
22
app/Views/site/home.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<section class="stack-lg" aria-labelledby="home-title">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 class="page-title" id="home-title">Articles</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<check if="{{ @posts }}">
|
||||||
|
<true>
|
||||||
|
<div class="card-grid">
|
||||||
|
<repeat group="{{ @posts }}" value="{{ @post }}">
|
||||||
|
<include href="partials/post_card.html" />
|
||||||
|
</repeat>
|
||||||
|
</div>
|
||||||
|
<include href="partials/pagination.html" />
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<section class="empty-state" aria-labelledby="home-empty-title">
|
||||||
|
<h2 class="card-title" id="home-empty-title">Aucun article</h2>
|
||||||
|
<p>Le premier article arrivera bientôt.</p>
|
||||||
|
</section>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
</section>
|
||||||
24
app/Views/site/post.html
Normal file
24
app/Views/site/post.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<article class="article" aria-labelledby="post-title">
|
||||||
|
<header class="article-header">
|
||||||
|
<h1 class="article-title" id="post-title">{{ @post.title }}</h1>
|
||||||
|
<p class="meta-text">
|
||||||
|
Publié le <time datetime="{{ @post.created_at }}">{{ @post.created_at_label }}</time>
|
||||||
|
<check if="{{ @post.has_updated_at }}">
|
||||||
|
<true><br>Mis à jour le <time datetime="{{ @post.updated_at }}">{{ @post.updated_at_label }}</time></true>
|
||||||
|
</check>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<check if="{{ @post.cover_url }}">
|
||||||
|
<true>
|
||||||
|
<img class="media-frame media-frame--large article-cover" src="{{ @post.cover_url }}"
|
||||||
|
alt="{{ @post.title }}">
|
||||||
|
</true>
|
||||||
|
<false>
|
||||||
|
<div class="media-frame media-frame--large media-frame--placeholder article-cover">Aucune image
|
||||||
|
</div>
|
||||||
|
</false>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<div class="prose">{{ @post.body_html | raw }}</div>
|
||||||
|
</article>
|
||||||
80
app/bootstrap.php
Normal file
80
app/bootstrap.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/Helpers/App.php';
|
||||||
|
require __DIR__ . '/Helpers/Error.php';
|
||||||
|
|
||||||
|
$f3 = Base::instance();
|
||||||
|
|
||||||
|
// ── Configuration ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$f3->set('AUTOLOAD', app_root() . '/app/Controllers/;' . app_root() . '/app/Models/;' . app_root() . '/app/Services/');
|
||||||
|
$f3->set('UI', app_root() . '/app/Views/');
|
||||||
|
$f3->set('TEMP', app_root() . '/tmp/');
|
||||||
|
$f3->set('LOGS', app_logs_dir() . '/');
|
||||||
|
|
||||||
|
$f3->config(app_root() . '/app/config.ini');
|
||||||
|
|
||||||
|
$localConfig = app_root() . '/config.local.ini';
|
||||||
|
if (is_file($localConfig)) {
|
||||||
|
$f3->config($localConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
$f3->set('TZ', app_timezone());
|
||||||
|
$f3->set('DEBUG', app_is_prod() ? 0 : 3);
|
||||||
|
|
||||||
|
app_ensure_dir((string) $f3->get('TEMP'));
|
||||||
|
app_ensure_dir((string) $f3->get('LOGS'));
|
||||||
|
app_ensure_dir(app_public_media_dir());
|
||||||
|
// Web::receive() utilise UPLOADS directement — le résoudre en absolu.
|
||||||
|
$f3->set('UPLOADS', app_root() . '/' . ltrim((string) $f3->get('UPLOADS'), '/'));
|
||||||
|
app_ensure_dir(rtrim((string) $f3->get('UPLOADS'), '/'));
|
||||||
|
app_bootstrap_logging();
|
||||||
|
|
||||||
|
// ── En-têtes de sécurité ────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
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'");
|
||||||
|
header('Referrer-Policy: same-origin');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: SAMEORIGIN');
|
||||||
|
header('Cross-Origin-Opener-Policy: same-origin');
|
||||||
|
header('Cross-Origin-Resource-Policy: same-origin');
|
||||||
|
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Base de données ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$dbPath = app_db_path();
|
||||||
|
app_ensure_dir(dirname($dbPath));
|
||||||
|
|
||||||
|
$db = new DB\SQL(
|
||||||
|
'sqlite:' . $dbPath,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_TIMEOUT => 5,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$db->exec('PRAGMA foreign_keys = ON');
|
||||||
|
$f3->set('DB', $db);
|
||||||
|
|
||||||
|
// ── Session ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
session_name((string) $f3->get('app.session_name'));
|
||||||
|
$f3->set('JAR', [
|
||||||
|
'expire' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => $f3->get('SCHEME') === 'https',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Erreurs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app_bootstrap_errors($f3);
|
||||||
|
|
||||||
|
return $f3;
|
||||||
32
app/config.ini
Normal file
32
app/config.ini
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[globals]
|
||||||
|
app.env=dev
|
||||||
|
app.timezone=UTC
|
||||||
|
app.session_name=f3-simple-blog
|
||||||
|
|
||||||
|
UPLOADS=tmp/uploads/
|
||||||
|
CACHE=folder
|
||||||
|
|
||||||
|
app.name=F3 Simple Blog
|
||||||
|
app.tagline=Blog simple avec Fat-Free Framework.
|
||||||
|
|
||||||
|
[routes]
|
||||||
|
GET @asset: /min/@file=AssetController->serve
|
||||||
|
|
||||||
|
GET @home: /=SiteController->home
|
||||||
|
GET @post_show: /posts/@slug=SiteController->show
|
||||||
|
|
||||||
|
GET @login: /login=AuthController->show
|
||||||
|
POST @login_submit: /login=AuthController->login
|
||||||
|
POST @logout: /logout=AuthController->logout
|
||||||
|
|
||||||
|
GET @dashboard: /dashboard=DashboardController->index
|
||||||
|
GET @post_create: /dashboard/posts/create=PostController->create
|
||||||
|
POST @post_store: /dashboard/posts=PostController->store
|
||||||
|
GET @post_edit: /dashboard/posts/@id/edit=PostController->edit
|
||||||
|
POST @post_update: /dashboard/posts/@id/update=PostController->update
|
||||||
|
POST @post_delete: /dashboard/posts/@id/delete=PostController->delete
|
||||||
|
|
||||||
|
GET @media_index: /dashboard/media=MediaController->index
|
||||||
|
POST @media_upload: /dashboard/media=MediaController->upload
|
||||||
|
POST @media_update_alt: /dashboard/media/@id/alt=MediaController->updateAlt
|
||||||
|
POST @media_delete: /dashboard/media/@id/delete=MediaController->delete
|
||||||
14
compose.yaml
Normal file
14
compose.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: f3-simple-blog:latest
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8888:80"
|
||||||
|
volumes:
|
||||||
|
- ./config.local.ini:/var/www/html/config.local.ini:ro
|
||||||
|
- ./db:/var/www/html/db
|
||||||
|
- ./logs:/var/www/html/logs
|
||||||
|
- ./tmp:/var/www/html/tmp
|
||||||
|
- ./public/uploads/media:/var/www/html/public/uploads/media
|
||||||
9
composer.json
Normal file
9
composer.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "netig/f3-simple-blog",
|
||||||
|
"description": "Blog simple avec Fat-Free Framework.",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"bcosca/fatfree-core": "^3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
composer.lock
generated
Normal file
66
composer.lock
generated
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "b223f9fed1c99d22eabd05b892ca4602",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "bcosca/fatfree-core",
|
||||||
|
"version": "3.9.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/f3-factory/fatfree-core.git",
|
||||||
|
"reference": "3ba261541e529d20b32615fe5f7b5740ea0951a3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/f3-factory/fatfree-core/zipball/3ba261541e529d20b32615fe5f7b5740ea0951a3",
|
||||||
|
"reference": "3ba261541e529d20b32615fe5f7b5740ea0951a3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"classmap": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"GPL-3.0"
|
||||||
|
],
|
||||||
|
"description": "A powerful yet easy-to-use PHP micro-framework designed to help you build dynamic and robust Web applications - fast!",
|
||||||
|
"homepage": "http://fatfreeframework.com/",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/f3-factory/fatfree-core/issues",
|
||||||
|
"source": "https://github.com/f3-factory/fatfree-core/tree/3.9.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.buymeacoffee.com/ikkez",
|
||||||
|
"type": "buy_me_a_coffee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/ikkez",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-02T00:44:50+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.6.0"
|
||||||
|
}
|
||||||
13
config.local.ini.example
Normal file
13
config.local.ini.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
; Copier ce fichier vers config.local.ini puis décommenter uniquement les valeurs à surcharger.
|
||||||
|
; Les défauts applicatifs restent dans app/config.ini.
|
||||||
|
; Les chemins runtime ne changent pas entre local et Docker :
|
||||||
|
; - tmp/cache pour le cache F3 et les assets minifiés
|
||||||
|
; - tmp/uploads pour les fichiers temporaires d'upload
|
||||||
|
; Les données persistantes restent hors de tmp : db/, logs/, public/uploads/media/
|
||||||
|
|
||||||
|
[globals]
|
||||||
|
; app.env=prod
|
||||||
|
; app.timezone=Europe/Paris
|
||||||
|
; app.session_name=f3-simple-blog
|
||||||
|
; app.name=F3 Simple Blog
|
||||||
|
; app.tagline=Blog simple avec Fat-Free Framework.
|
||||||
0
db/.gitkeep
Normal file
0
db/.gitkeep
Normal file
15
docker/apache-vhost.conf
Normal file
15
docker/apache-vhost.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<VirtualHost *:80>
|
||||||
|
ServerAdmin webmaster@localhost
|
||||||
|
DocumentRoot /var/www/html/public
|
||||||
|
|
||||||
|
<Directory /var/www/html/public>
|
||||||
|
Options FollowSymLinks
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
DirectoryIndex index.php
|
||||||
|
FallbackResource /index.php
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog /proc/self/fd/2
|
||||||
|
CustomLog /proc/self/fd/1 combined
|
||||||
|
</VirtualHost>
|
||||||
38
docker/entrypoint.sh
Normal file
38
docker/entrypoint.sh
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_ROOT="/var/www/html"
|
||||||
|
CONFIG="$APP_ROOT/config.local.ini"
|
||||||
|
|
||||||
|
# Docker creates a directory when bind-mounting a file that doesn't exist on the host.
|
||||||
|
# Remove it so bootstrap.php falls back to defaults.
|
||||||
|
if [ -d "$CONFIG" ]; then
|
||||||
|
rmdir "$CONFIG" 2>/dev/null || true
|
||||||
|
echo "Warning: config.local.ini was mounted as a directory (file missing on host). Using defaults."
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0775 -o www-data -g www-data \
|
||||||
|
"$APP_ROOT/db" \
|
||||||
|
"$APP_ROOT/logs" \
|
||||||
|
"$APP_ROOT/public/uploads/media" \
|
||||||
|
"$APP_ROOT/tmp" \
|
||||||
|
"$APP_ROOT/tmp/cache" \
|
||||||
|
"$APP_ROOT/tmp/uploads"
|
||||||
|
|
||||||
|
# Bind mounts may keep host-side ownership/permissions. Normalize the writable
|
||||||
|
# application directories before boot so F3 can write its cache and SQLite files.
|
||||||
|
chown -R www-data:www-data \
|
||||||
|
"$APP_ROOT/db" \
|
||||||
|
"$APP_ROOT/logs" \
|
||||||
|
"$APP_ROOT/public/uploads/media" \
|
||||||
|
"$APP_ROOT/tmp"
|
||||||
|
chmod -R u+rwX,g+rwX \
|
||||||
|
"$APP_ROOT/db" \
|
||||||
|
"$APP_ROOT/logs" \
|
||||||
|
"$APP_ROOT/public/uploads/media" \
|
||||||
|
"$APP_ROOT/tmp"
|
||||||
|
|
||||||
|
# Run installation as the web user so generated files keep consistent ownership.
|
||||||
|
su -s /bin/sh www-data -c "php $APP_ROOT/scripts/install.php"
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
12
docker/php-prod.ini
Normal file
12
docker/php-prod.ini
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
expose_php = Off
|
||||||
|
log_errors = On
|
||||||
|
display_errors = Off
|
||||||
|
error_log = /var/www/html/logs/php-error.log
|
||||||
|
session.use_strict_mode = 1
|
||||||
|
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.enable_cli = 0
|
||||||
|
opcache.validate_timestamps = 0
|
||||||
|
opcache.memory_consumption = 128
|
||||||
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.max_accelerated_files = 10000
|
||||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
1070
public/assets/app.css
Normal file
1070
public/assets/app.css
Normal file
File diff suppressed because it is too large
Load Diff
234
public/assets/app.js
Normal file
234
public/assets/app.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
(() => {
|
||||||
|
const on = (selector, handler) => document.querySelectorAll(selector).forEach(handler);
|
||||||
|
|
||||||
|
on('[data-copy-text]', (button) => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const text = button.getAttribute('data-copy-text') || '';
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const previous = button.textContent;
|
||||||
|
button.textContent = 'Copié';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.textContent = previous;
|
||||||
|
}, 1200);
|
||||||
|
} catch {
|
||||||
|
window.prompt('Copie ce texte :', text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
on('[data-confirm]', (form) => {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
const message = form.getAttribute('data-confirm') || 'Confirmer cette action ?';
|
||||||
|
if (!window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
on('[data-char-count]', (field) => {
|
||||||
|
const counter = field.parentElement?.querySelector('[data-char-count-value]');
|
||||||
|
if (!counter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
counter.textContent = String(field.value.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
field.addEventListener('input', update);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = document.querySelector('[data-markdown-editor]');
|
||||||
|
const picker = document.querySelector('[data-media-picker]');
|
||||||
|
const editorLayout = document.querySelector('[data-editor-layout]');
|
||||||
|
const pickerTitle = document.querySelector('[data-media-picker-title]');
|
||||||
|
const pickerHelp = document.querySelector('[data-media-picker-help]');
|
||||||
|
const pickerClose = document.querySelector('[data-media-picker-close]');
|
||||||
|
const coverInput = document.querySelector('[data-cover-input]');
|
||||||
|
const coverPreview = document.querySelector('[data-cover-preview]');
|
||||||
|
const coverPlaceholder = document.querySelector('[data-cover-placeholder]');
|
||||||
|
const coverClear = document.querySelector('[data-cover-clear]');
|
||||||
|
let pickerMode = 'markdown';
|
||||||
|
|
||||||
|
const focusEditor = () => editor?.focus();
|
||||||
|
|
||||||
|
const updateCoverPreview = (item = null) => {
|
||||||
|
if (!coverInput || !coverPreview || !coverPlaceholder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item && item.id && item.url) {
|
||||||
|
coverInput.value = item.id;
|
||||||
|
coverPreview.src = item.url;
|
||||||
|
coverPreview.alt = 'Image de couverture';
|
||||||
|
coverPreview.classList.remove('is-hidden');
|
||||||
|
coverPlaceholder.classList.add('is-hidden');
|
||||||
|
if (coverClear) {
|
||||||
|
coverClear.disabled = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
coverInput.value = '';
|
||||||
|
coverPreview.removeAttribute('src');
|
||||||
|
coverPreview.classList.add('is-hidden');
|
||||||
|
coverPlaceholder.classList.remove('is-hidden');
|
||||||
|
if (coverClear) {
|
||||||
|
coverClear.disabled = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
coverClear?.addEventListener('click', () => updateCoverPreview());
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
const replaceSelection = (before, after = '', placeholder = '') => {
|
||||||
|
const start = editor.selectionStart;
|
||||||
|
const end = editor.selectionEnd;
|
||||||
|
const selected = editor.value.slice(start, end);
|
||||||
|
const content = selected || placeholder;
|
||||||
|
const insertion = before + content + after;
|
||||||
|
|
||||||
|
editor.setRangeText(insertion, start, end, 'end');
|
||||||
|
|
||||||
|
if (!selected && placeholder) {
|
||||||
|
const cursorStart = start + before.length;
|
||||||
|
editor.setSelectionRange(cursorStart, cursorStart + placeholder.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefixLines = (prefix, placeholder) => {
|
||||||
|
const start = editor.selectionStart;
|
||||||
|
const end = editor.selectionEnd;
|
||||||
|
const selected = editor.value.slice(start, end) || placeholder;
|
||||||
|
const prefixed = selected
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (line ? prefix + line : prefix.trimEnd()))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
editor.setRangeText(prefixed, start, end, 'end');
|
||||||
|
focusEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
on('[data-md-action]', (button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
switch (button.getAttribute('data-md-action')) {
|
||||||
|
case 'bold':
|
||||||
|
replaceSelection('**', '**', 'texte');
|
||||||
|
break;
|
||||||
|
case 'italic':
|
||||||
|
replaceSelection('*', '*', 'texte');
|
||||||
|
break;
|
||||||
|
case 'heading':
|
||||||
|
prefixLines('## ', 'Titre');
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
prefixLines('- ', 'Élément');
|
||||||
|
break;
|
||||||
|
case 'quote':
|
||||||
|
prefixLines('> ', 'Citation');
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
replaceSelection('[', '](https://)', 'texte');
|
||||||
|
break;
|
||||||
|
case 'code': {
|
||||||
|
const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||||
|
replaceSelection(
|
||||||
|
selected.includes('\n') ? '```\n' : '`',
|
||||||
|
selected.includes('\n') ? '\n```' : '`',
|
||||||
|
'code'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePicker = (open, mode = pickerMode) => {
|
||||||
|
if (!picker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pickerMode = mode;
|
||||||
|
picker.classList.toggle('is-hidden', !open);
|
||||||
|
editorLayout?.classList.toggle('is-picker-open', open);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
focusEditor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCover = pickerMode === 'cover';
|
||||||
|
if (pickerTitle) {
|
||||||
|
pickerTitle.textContent = isCover ? 'Choisir une couverture' : 'Insérer une image';
|
||||||
|
}
|
||||||
|
if (pickerHelp) {
|
||||||
|
pickerHelp.textContent = isCover
|
||||||
|
? 'Clique sur une image pour l’utiliser comme couverture.'
|
||||||
|
: 'Clique sur une image pour l’insérer dans l’article.';
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
on('[data-media-picker-open]', (button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
togglePicker(true, button.getAttribute('data-media-picker-open') || 'markdown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pickerClose?.addEventListener('click', () => togglePicker(false));
|
||||||
|
|
||||||
|
on('[data-media-picker-select]', (button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const item = {
|
||||||
|
id: button.getAttribute('data-media-id') || '',
|
||||||
|
url: button.getAttribute('data-media-url') || '',
|
||||||
|
markdown: button.getAttribute('data-media-markdown') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pickerMode === 'cover') {
|
||||||
|
updateCoverPreview(item);
|
||||||
|
togglePicker(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editor || !item.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 + item.markdown + suffix, start, end, 'end');
|
||||||
|
togglePicker(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synchronise data-copy-text du bouton Markdown quand l'alt est modifié.
|
||||||
|
on('[data-alt-input]', (input) => {
|
||||||
|
const card = input.closest('.card');
|
||||||
|
const button = card?.querySelector('[data-markdown-template]');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const template = button.getAttribute('data-markdown-template') || '';
|
||||||
|
const alt = input.value;
|
||||||
|
button.setAttribute('data-copy-text', template.replace(');
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', update);
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
})();
|
||||||
4
public/assets/favicon.svg
Normal file
4
public/assets/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#111827"/>
|
||||||
|
<path d="M18 18h28v8H26v10h18v8H26v12h-8z" fill="#f8fafc"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 186 B |
8
public/index.php
Normal file
8
public/index.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$f3 = require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
$f3->run();
|
||||||
0
public/uploads/media/.gitkeep
Normal file
0
public/uploads/media/.gitkeep
Normal file
7
scripts/bootstrap.php
Normal file
7
scripts/bootstrap.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
return require dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
37
scripts/create-admin.php
Normal file
37
scripts/create-admin.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$f3 = require __DIR__ . '/bootstrap.php';
|
||||||
|
$db = $f3->get('DB');
|
||||||
|
|
||||||
|
$username = trim((string) ($argv[1] ?? ''));
|
||||||
|
if ($username === '') {
|
||||||
|
fwrite(STDERR, "Usage: php scripts/create-admin.php <username>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, 'Mot de passe (10 caractères minimum): ');
|
||||||
|
if (stripos(PHP_OS, 'WIN') !== 0) {
|
||||||
|
system('stty -echo');
|
||||||
|
}
|
||||||
|
$password = trim((string) fgets(STDIN));
|
||||||
|
if (stripos(PHP_OS, 'WIN') !== 0) {
|
||||||
|
system('stty echo');
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, PHP_EOL);
|
||||||
|
|
||||||
|
if ($password === '') {
|
||||||
|
fwrite(STDERR, "Mot de passe vide.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
User::bootstrap($db);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$id = (new User($db))->create($username, $password);
|
||||||
|
fwrite(STDOUT, "Admin créé (ID {$id}).\n");
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
fwrite(STDERR, $e->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
12
scripts/install.php
Normal file
12
scripts/install.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$f3 = require __DIR__ . '/bootstrap.php';
|
||||||
|
$db = $f3->get('DB');
|
||||||
|
|
||||||
|
User::bootstrap($db);
|
||||||
|
Post::bootstrap($db);
|
||||||
|
Media::bootstrap($db);
|
||||||
|
|
||||||
|
fwrite(STDOUT, 'Base initialisée : ' . app_db_path() . "\n");
|
||||||
0
tmp/.gitkeep
Normal file
0
tmp/.gitkeep
Normal file
0
tmp/cache/.gitkeep
vendored
Normal file
0
tmp/cache/.gitkeep
vendored
Normal file
0
tmp/uploads/.gitkeep
Normal file
0
tmp/uploads/.gitkeep
Normal file
Reference in New Issue
Block a user