First commit

This commit is contained in:
julien
2026-03-27 14:43:08 +01:00
commit ced7dbfbf7
54 changed files with 3680 additions and 0 deletions

17
.dockerignore Normal file
View 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
View 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
View 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
View 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`

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

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

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

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

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

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

View 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
View 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
View 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
View 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' => '![' . $alt . '](media:' . $row['file_name'] . ')',
];
}
}

223
app/Models/Post.php Normal file
View 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
View 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 dutilisateur 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');
}
}

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

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

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

View 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 dimage 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
View 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 dutilisateur</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>

View 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 ladresse ou reviens à laccueil.</p>
<p class="error-page__actions"><a class="button" href="{{ @BASE }}/">Retour à laccueil</a></p>
</section>
</div>
</main>
</body>
</html>

24
app/Views/layout.html Normal file
View 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>

View 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="![](media:{{ @item.file_name }})">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>

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

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

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

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

View File

@@ -0,0 +1 @@
<a class="site-brand__title" href="{{ @BASE }}/">{{ @app.name }}</a>

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

15
docker/apache-vhost.conf Normal file
View 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
View 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
View 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
View 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
View 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 lutiliser comme couverture.'
: 'Clique sur une image pour linsérer dans larticle.';
}
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('![](', '![' + alt + ']('));
};
input.addEventListener('input', update);
update();
});
})();

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

View File

7
scripts/bootstrap.php Normal file
View 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
View 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
View 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
View File

0
tmp/cache/.gitkeep vendored Normal file
View File

0
tmp/uploads/.gitkeep Normal file
View File