get('DB'), 'media'); } public static function bootstrap(DB\SQL $db): void { if ($db->schema('media', null, 0)) { return; } $db->exec('CREATE TABLE 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 idx_media_created_at ON media(created_at DESC)'); } public function page(int $page, int $perPage): array { $result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']); $items = array_map(fn(self $row): array => $this->decorate($row->cast()), $result['subset'] ?: []); return [ 'items' => $items, 'pagination' => [ 'page' => max(1, min($page, $result['count'] ?: 1)), 'pages' => max(1, (int) ($result['count'] ?: 1)), ], ]; } 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()); } public function upload(string $temporaryPath, string $originalName = ''): int { $f3 = Base::instance(); if (!is_file($temporaryPath)) { throw new RuntimeException('Fichier image introuvable.'); } $binary = $f3->read($temporaryPath); $image = new Image(); if ($binary === '' || $image->load($binary) === false) { @unlink($temporaryPath); throw new RuntimeException('Fichier image invalide.'); } $info = @getimagesizefromstring($binary); $mime = strtolower((string) ($info['mime'] ?? '')); $extension = match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', default => null, }; if ($extension === null) { @unlink($temporaryPath); throw new RuntimeException('Format non supporté. Utilise JPG ou PNG.'); } $encoded = match ($extension) { 'jpg' => $image->dump('jpeg', 90), 'png' => $image->dump('png'), }; $fileName = bin2hex(random_bytes(16)) . '.' . $extension; $target = $this->storagePath($fileName); try { $f3->write($target, $encoded); } catch (Throwable $e) { @unlink($temporaryPath); throw new RuntimeException('Impossible d’enregistrer cette image.', 0, $e); } @unlink($temporaryPath); try { $this->reset(); $this->file_name = $fileName; $this->alt = $this->guessAlt($originalName); $this->width = $image->width(); $this->height = $image->height(); $this->created_at = app_now(); $this->save(); } catch (Throwable $e) { @unlink($target); throw new RuntimeException('Impossible de finaliser cette image.', 0, $e); } return (int) $this->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 deleteById(int $id): void { $this->load(['id = ?', $id]); if ($this->dry()) { throw new RuntimeException('Image introuvable.'); } try { $this->erase(); } catch (Throwable $e) { throw new RuntimeException('Impossible de supprimer cette image.', 0, $e); } } private function decorate(array $row): array { $fileName = (string) $row['file_name']; $alt = (string) $row['alt']; $base = rtrim((string) Base::instance()->get('BASE'), '/'); $mediaBase = rtrim((string) Base::instance()->get('paths.media_base'), '/'); return [ 'id' => (int) $row['id'], 'file_name' => $fileName, 'alt' => $alt, 'width' => (int) $row['width'], 'height' => (int) $row['height'], 'created_at' => (string) $row['created_at'], 'url' => $base . $mediaBase . '/' . rawurlencode($fileName), 'markdown' => '![' . $alt . '](media:' . $fileName . ')', ]; } private function storagePath(string $fileName): string { return rtrim((string) Base::instance()->get('paths.media_dir'), '/\\') . DIRECTORY_SEPARATOR . $fileName; } private function guessAlt(string $originalName): string { $label = trim(pathinfo($originalName, PATHINFO_FILENAME)); $label = preg_replace('/[-_]+/', ' ', $label) ?: ''; return trim($label); } }