10

Rozdział 10 z 21

Obrazki w artykule

Opublikowany

Dodajemy obrazki na dwa sposoby: inline w treści Markdown (![alt](/path)) oraz jako galerię powiązaną z artykułem przez encję BlogMedia. Zadbamy o dostępność (alt text), responsywność i figure/figcaption.

Czego się nauczysz

  • Obrazki inline w Markdown (![alt](/path))
  • Encja BlogMedia (typ image, path, altText, position)
  • Galeria: figure/figcaption i serwowanie z public/
  • Dostępność (alt) i responsywność

Rozdział 10: Obrazki w artykule

Dodajemy obrazki na dwa sposoby: inline w treści Markdown oraz jako galerię powiązaną z artykułem przez encję BlogMedia. Zadbamy o dostępność (alt text) i responsywność. W tym rozdziale budujemy encję i wyświetlanie — sam upload w panelu admina dojdzie w Rozdziale 12.

Stan wejściowy: render Markdown (Rozdz. 5) i stylowanie .blog-content (Rozdz. 9). Tu dokładamy encję BlogMedia i galerię na stronie artykułu.


Obrazki inline w Markdown

Najprostsza droga — obrazek w treści artykułu, składnią Markdown:

![Schemat architektury](/images/blog/architektura.png)

To działa od razu (render z Rozdziału 5), a wygląd zapewnia reguła z Rozdziału 9:

.blog-content img { max-width: 100%; border-radius: .5rem; margin: 1rem 0; }
  • max-width: 100% — obrazek nigdy nie wyjdzie poza kolumnę (responsywność).
  • Pliki trzymamy w public/ (np. public/images/blog/…) i odwołujemy się ścieżką absolutną od web roota — Nginx serwuje public/ bezpośrednio.
  • Tekst w nawiasach kwadratowych to alt — opis dla czytników ekranu i gdy obrazek się nie wczyta.

Inline nadaje się do obrazków wplecionych w tekst. Do zestawu zdjęć powiązanych z artykułem lepsza jest osobna galeria — encja BlogMedia.


Encja BlogMedia

Jeden artykuł może mieć wiele plików (obrazki, a w Rozdziale 11 też wideo/audio) z zachowaniem kolejności. Dlatego trzymamy je w osobnej tabeli blog_media:

// src/Entity/BlogMedia.php
#[ORM\Entity(repositoryClass: BlogMediaRepository::class)]
#[ORM\Table(name: 'blog_media')]
#[ORM\Index(fields: ['article'], name: 'idx_blog_media_article_id')]
class BlogMedia
{
    public const TYPE_IMAGE = 'image';
    public const TYPE_VIDEO = 'video';
    public const TYPE_AUDIO = 'audio';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: BlogArticle::class, inversedBy: 'media')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?BlogArticle $article = null;

    #[ORM\Column(length: 10)]
    private string $type = self::TYPE_IMAGE;

    #[ORM\Column(length: 500)]
    private ?string $path = null;              // ścieżka względem public/

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $originalName = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $altText = null;           // opis (alt) — dostępność

    #[ORM\Column(options: ['default' => 0])]
    private int $position = 0;                  // kolejność w galerii

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }
}

Teraz dokładamy stronę odwrotną do BlogArticle — kolekcję media, którą odłożyliśmy w Rozdziale 3:

// src/Entity/BlogArticle.php — dodajemy kolekcję media
/** @var Collection<int, BlogMedia> */
#[ORM\OneToMany(targetEntity: BlogMedia::class, mappedBy: 'article', cascade: ['persist', 'remove'])]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $media;

public function __construct()
{
    // … pozostałe kolekcje …
    $this->media = new ArrayCollection();
}
  • onDelete: 'CASCADE' — usunięcie artykułu kasuje jego media na poziomie bazy.
  • OrderBy(['position' => 'ASC']) — galeria zawsze w ustalonej kolejności.
  • cascade: ['persist', 'remove'] — media zapisują/usuwają się razem z artykułem.

Generujemy migrację tabeli blog_media:

docker compose exec app php bin/console make:migration
docker compose exec app php bin/console doctrine:migrations:migrate

Skąd wezmą się media? Na razie z fixtures (albo ręcznie w bazie) — formularz uploadu w panelu admina dobudujemy w Rozdziale 12. Przykładowy wpis w BlogFixtures:

$media = new BlogMedia();
$media->setArticle($article)->setType(BlogMedia::TYPE_IMAGE)
    ->setPath('images/blog/architektura.png')->setAltText('Schemat architektury')->setPosition(0);
$manager->persist($media);

Galeria obrazków

Na stronie artykułu renderujemy tylko media typu image (wideo/audio dojdą w Rozdziale 11), każde jako <figure> z opcjonalnym podpisem. Kolekcja article.media jest już posortowana po position:

{# templates/blog/show.html.twig — galeria pod treścią artykułu #}
{% set images = article.media|filter(m => m.type == 'image') %}
{% if images|length > 0 %}
    <div class="mb-4">
        {% for img in images %}
            <figure class="figure w-100">
                <img src="/{{ img.path }}" alt="{{ img.altText }}"
                     class="figure-img img-fluid rounded shadow-sm w-100"
                     style="max-height: 450px; object-fit: cover;"
                     onerror="this.parentElement.style.display='none'">
                {% if img.altText %}
                    <figcaption class="figure-caption text-center">{{ img.altText }}</figcaption>
                {% endif %}
            </figure>
        {% endfor %}
    </div>
{% endif %}
  • article.media|filter(m => m.type == 'image') — z całej kolekcji wybieramy same obrazki.
  • <figure> + <figcaption> — semantyczny obrazek z podpisem (opartym na altText).
  • src="/{{ img.path }}" — ścieżka jest względem public/, więc dokładamy wiodący /.

Dostępność i responsywność

Kilka drobiazgów, które robią dużą różnicę:

  • alt="{{ img.altText }}" — zawsze ustawiamy atrybut alt. Dla obrazków dekoracyjnych może być pusty, dla treściowych — opisowy (czytniki ekranu, SEO, fallback).
  • img-fluid (Bootstrap) + max-width: 100% — obrazek skaluje się do szerokości kolumny na każdym urządzeniu.
  • object-fit: cover + max-height — miniatury galerii mają równą wysokość bez zniekształceń.
  • onerror="this.parentElement.style.display='none'" — jeśli plik zniknął, chowamy całą figurę zamiast pokazywać „złamany” obrazek.

Podsumowanie

Artykuł może mieć obrazki na dwa sposoby:

  • ✅ inline w Markdown (![alt](/path)) — serwowane z public/, responsywne przez .blog-content img,
  • ✅ encja BlogMedia (typ, path, altText, position) + kolekcja media dołożona do BlogArticle (OrderBy, onDelete: CASCADE) i migracja blog_media,
  • ✅ galeria — figure/figcaption, filtr type == 'image', kolejność po position,
  • ✅ dostępność i responsywność — alt, img-fluid, object-fit, fallback onerror,
  • ✅ działający stan: obrazki z treści i galerii wyświetlają się na stronie artykułu (media na razie z fixtures — upload w Rozdziale 12).

W Rozdziale 11 rozszerzymy media o wideo i audio — te same BlogMedia, ale osadzone przez natywne <video> i <audio>.

Narzędzia / paczki

BlogMedia Twig Markdown

Spis treści