Skip to content

omnibase/gallery

Albums of photographs for omnibase: an Album is a Thread, its Photos are ordered, one album is one creation.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/gallery:dev-main

omnibase/gallery is the portfolio of a site of the omnibase family: albums of photographs - a sign maker's works, a studio's projects, a craftsman's before and after - listed, filtered by tag and by taxon, each with its photographs in order. Its namespace is Base\Gallery\; it needs omnisense/omnibase alone, and registers its back office screens when omnibase/admin is installed.

What it adds

  • Album, an omnisense/omnibase Thread (title, text, slug, state, tags, taxa... are the thread's) with the day the work was made, a "featured" flag and its photographs; Photo, a picture with its caption, alternative text, credit, place, cover flag and label.
  • Three routes under gallery.path (galerie by default): the list, a taxon's page and an album's page.
  • schema.org's ImageGallery on an album, the albums and taxa in /sitemap.xml.
  • Three Twig functions and three partials for a site's own pages.
  • Two back office screens with Publish and Featured.
  • English, French and German messages (domain gallery).

Pages

Propose a change docs/index.md

Installation and configuration

composer require omnibase/gallery:dev-main
// config/bundles.php
Base\Gallery\GalleryBundle::class => ['all' => true],
# config/routes.yaml
gallery_controller:
    resource: "@GalleryBundle/src/Controller/Client"
    type: attribute
    prefix: /
bin/console make:migration && bin/console doctrine:migrations:migrate   # gallery_album, gallery_photo
bin/console assets:install public --symlink --relative                  # bundles/gallery/css/gallery.css
bin/console cache:pool:clear --all                                      # omnibase keeps the routes in a pool

It needs omnisense/omnibase alone; with omnibase/admin installed the back office screens are registered as well.

Requirements

  • PHP 8.2 or later;
  • doctrine/orm 2.15 or 3;
  • omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x;
  • Symfony 6, 7 or 8 (config, dependency-injection, form, http-kernel).

Suggested: omnibase/admin, for the back office screens.

Configuration

# config/packages/gallery.yaml
gallery:
    path: galerie                              # /galerie, /galerie/{slug}; "realisations", "photos", "en/gallery"...
    per_page: 24                               # albums on a page of the list
    taxon_class: Base\Entity\Thread\Taxon      # the taxa that have a page of their own
    sitemap: true                              # albums and taxa pages in /sitemap.xml

Every key is optional; the configuration reference says what each one does.

Propose a change docs/installation.md

Albums and photographs

What it stores

Entity Table
Base\Gallery\Entity\Album gallery_album (JOINED on omnibase's thread, discriminator gallery_album) madeAt (the day the work was made), featured, photos
Base\Gallery\Entity\Photo gallery_photo album, file (an upload, 32 MB, images), caption, alt, credit, position, cover, label

Everything else an album has is the thread's: title, excerpt, content (translated), slug, state, publishedAt, tags, taxa, owners, connexes (other threads it is linked to).

$album = new Album($user, null, 'Enseigne de la boulangerie');
$album->setMadeAt(new \DateTime('2026-03-14'))->setFeatured(true);
$album->addPhoto((new Photo())->setFile(new File($path))->setCaption('De nuit')->setLabel('Après'));
$album->publish();                    // PUBLISH, dated now; publish(false): back to draft
$em->persist($album);

$album->getOrderedPhotos();           // by place, then in the order added
$album->getCover();                   // the photo marked, else the first
$album->getPhotosByLabel();           // ['Avant' => [...], 'Après' => [...]]
$album->renumberPhotos();             // places 1, 2, 3...

An album

Method Does
addPhoto($photo) adds it once, at the next place
removePhoto($photo) takes it out
getOrderedPhotos() the photographs by place, then in the order added
getCover(), setCover($photo) the photograph marked as the cover, else the first; marking one unmarks the others
getPhotosByLabel() the photographs carrying a label ("Avant", "Après"), by label, in order
renumberPhotos() rewrites the places 1, 2, 3...
publish(bool $published = true, ?DateTimeInterface $at = null) published now (or at $at), the publication date set the first time; false, back to draft

A photograph

Method Returns
getFile(), hasFile() the stored picture's public path; whether one is set, without asking the storage
getFileUrl() its address on the site (the public directory taken off), for JSON-LD
getAlt() what a screen reader says: the alt typed, else the caption, else the album's title; empty without an album
getOwnAlt() the alt as typed, or null
getCreditLine() the credit with its sign: "Anna Meyer" is printed "© Anna Meyer"; a credit opening with ©, (c), "photo" or "foto" is left as it is

Captions, alts, credits and labels are trimmed; an empty one is null. The picture is optional at the database level (missable): a photograph whose file went missing is kept.

The repository

Base\Gallery\Repository\AlbumRepository:

Method Returns
findPublishedPage($page, $perPage, $taxon, $tag, $featured) a page of the published albums, featured first, then the most recent work
countPublished($taxon, $tag, $featured) how many
findLatest($limit = 6, $featured, $taxon) the first ones of the list
findAllPublished() every published album, for the sitemap
findOnePublished($slug) one published album
findNeighbours($album) the previous and the next album of the list; none for an album that is not listed
findTags($taxon) the tags of the published albums (of one taxon), by priority then slug
findTaxa($class) the taxa that have a page, by position when the class has one
findTaxon($slug, $class) one of them

The repository takes its entity class as an argument, so an application's own album class can have a repository extending this one (Extending Album in an application).

Propose a change docs/concepts.md

Pages, Twig and styles

The routes

Route Path
gallery_index /{path} every published album; ?tag=slug filters, ?page=2 pages
gallery_taxon /{path}/{slug} the albums of one taxon, with its label and description
gallery_album /{path}/{slug} one album

A taxon's page and an album's page share one pattern: the taxon is looked for first. Give an album another slug than a taxon's.

The list's order: the featured albums first, then the most recent work (madeAt, else the publication date).

Sitemap

Base\Gallery\EventListener\SitemapListener adds every published album and every taxon page to omnibase's /sitemap.xml (SitemapEvent::BUILD), the albums with the day they last changed.

JSON-LD

An album's page carries schema.org's ImageGallery (Base\Gallery\Service\JsonLd::album()): its name, description, dates, keywords (its tags) and each photograph as an ImageObject with its address, caption and credit.

In a site's own pages

{# the six latest albums, the featured ones first #}
{% for album in gallery_albums(6) %}
    {% include '@Gallery/client/_album_card.html.twig' with {album: album} only %}
{% endfor %}

{{ gallery_albums(3, true) }}                 {# three featured albums #}
{{ gallery_albums(4, null, 'enseigne') }}     {# four of one taxon, by its slug… #}
{{ gallery_albums(4, null, category) }}       {# …or the taxon itself #}
{{ gallery_count() }} {{ gallery_count('enseigne') }}
{% for taxon in gallery_taxa() %}<a href="{{ path('gallery_taxon', {slug: taxon.slug}) }}">{{ taxon }}</a>{% endfor %}
Function Returns
gallery_albums(limit = 6, featured = null, taxon = null) the published albums, in the list's order; featured: true only the featured ones, false only the others
gallery_count(taxon = null) how many are published
gallery_taxa() the taxa that have a page (gallery.taxon_class)

The partials

Partial Takes
@Gallery/client/_album_card.html.twig album, heading (the title's level, 3) cover, year, title, preview; block gallery_card_meta
@Gallery/client/_photos.html.twig photos, group (the lightbox's name) each photograph through omnibase's `
@Gallery/client/_filters.html.twig taxa, taxon, tags, tag the taxa (pages) and the tags (?tag=)

The pictures go through omnibase's |picture (cards) and |lightbox (an album's photographs: a thumbnail that opens the full picture, srcset at 800 and 1600).

The pages and their blocks

@Gallery/client/index.html.twig and @Gallery/client/album.html.twig extend the site's layout1.html.twig. A site overrides them in templates/bundles/GalleryBundle/client/ - whole, or block by block by extending the original with @!Gallery:

{# templates/bundles/GalleryBundle/client/album.html.twig #}
{% extends '@!Gallery/client/album.html.twig' %}

{% block gallery_album_after %}
    <a href="{{ path('app_quote', {like: album.slug}) }}">Un projet comme celui-ci ?</a>
{% endblock %}
Template Blocks
index.html.twig gallery_head, gallery_filters, gallery_list
album.html.twig gallery_album_head, gallery_album_photos, gallery_album_body, gallery_album_after

Styles

bundles/gallery/css/gallery.css is linked by the two pages. It is neutral: it takes the page's font and colour, and a site sets its own on :root (or on any element above: the cards and the photographs work outside .gallery too, on a home page).

:root {
    --gallery-accent: #d4382d;       /* the current filter, labels, focus */
    --gallery-accent-ink: #fff;
    --gallery-ink: #000;
    --gallery-muted: #555;
    --gallery-line: #ddd;
    --gallery-surface: #f4f3ed;      /* behind a picture that loads */
    --gallery-gap: 1.5rem;
    --gallery-radius: 0;
    --gallery-columns-min: 17rem;    /* a card's least width */
    --gallery-ratio: 4 / 3;          /* a card's cover */
    --gallery-max: 76rem;
}

No scroll library and no animation tied to scrolling: the page scrolls the way the browser does.

Propose a change docs/templates.md

The back office

With omnibase/admin installed, two screens are registered (they are open to ROLE_ADMIN):

Screen Controller
Albums Base\Gallery\Controller\Admin\Crud\AlbumCrudController title, state, slug, the day the work was made, publication date, featured, preview, text, tags, taxa, and the photographs
Photographs Base\Gallery\Controller\Admin\Crud\PhotoCrudController every photograph, album by album and in order: to fix a caption or a credit without opening the album
// App\Controller\Admin\DashboardController::configureWidgetItems()
yield MenuItem::section('Galerie', 'fa-solid fa-images')->setSubItems([
    MenuItem::linkToCrud(\Base\Gallery\Entity\Album::class, 'Albums', 'fa-solid fa-images'),
    MenuItem::linkToCrud(\Base\Gallery\Entity\Photo::class, 'Photos', 'fa-solid fa-camera'),
]);

The photographs of an album

They are typed in the album's own form (Base\Gallery\Form\PhotoType: the picture, its place, caption, alternative text, credit, label, cover). Saved, the album rewrites the places 1, 2, 3… in the order typed - a photograph given the place of another comes before the ones added after it - and keeps one cover at most (none ticked: the first photograph).

Publier, À la une

Four actions on the list, the detail and the form (#[AdminAction], POST with a CSRF token): Publier / Dépublier (Album::publish(): the state, and the publication date the first time), Mettre à la une / Retirer de la une (featured). Only the one that applies is shown.

Propose a change docs/admin.md

Extending Album in an application

An album is a creation; what a creation is belongs to the site. A sign maker's realisation has a customer, a town and a technical sheet; a caterer's event has a number of guests. That is a subclass of Album in the application - not a field added here, and not another bundle.

The entity

namespace App\Entity\Portfolio;

use App\Repository\Portfolio\RealisationRepository;
use Base\Database\Attribute\DiscriminatorEntry;
use Base\Gallery\Entity\Album;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: RealisationRepository::class)]
#[ORM\Table(name: 'portfolio_realisation')]
#[DiscriminatorEntry(value: 'portfolio_realisation')]     // JOINED on thread, like Album on Thread
class Realisation extends Album
{
    #[ORM\Column(length: 160, nullable: true)]
    protected ?string $client = null;

    #[ORM\Column(type: 'json')]
    protected array $facts = [];                           // [{label, value}]: support, format, pose

    // getters and setters
}

Its row is three tables joined: thread, gallery_album, portfolio_realisation. A migration creates the third.

The repository

Every query of Base\Gallery\Repository\AlbumRepository is on Album: with JOINED inheritance a Realisation comes back as a Realisation, so the pages, gallery_albums() and the sitemap need nothing. For the site's own queries, extend it and pass the class:

class RealisationRepository extends AlbumRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Realisation::class);   // its queries now return realisations only
    }
}

The taxa

The taxa that get a page are those of gallery.taxon_class. A site whose albums are classified by its own taxon (a subclass of omnibase's Taxon, with an introduction and a cover, say) names it:

gallery:
    path: realisations
    taxon_class: App\Entity\Portfolio\Category

/realisations/enseigne is then the page of the Category whose slug is enseigne, with taxon in the template being that Category.

The pages

Override the templates in templates/bundles/GalleryBundle/client/ and extend the originals block by block (@!Gallery/...): album is the subclass, its own fields are there.

{% extends '@!Gallery/client/album.html.twig' %}

{% block gallery_album_body %}
    <dl>{% for fact in album.facts %}<dt>{{ fact.label }}</dt><dd>{{ fact.value }}</dd>{% endfor %}</dl>
    {{ parent() }}
{% endblock %}

The back office

Extend the album's screen: the class, then the fields.

namespace App\Controller\Admin\Crud\Portfolio;

use App\Entity\Portfolio\Realisation;
use Base\Field\TextField;
use Base\Gallery\Controller\Admin\Crud\AlbumCrudController;

class RealisationCrudController extends AlbumCrudController
{
    public static function getEntityFqcn(): string
    {
        return Realisation::class;
    }

    public function configureFields(string $pageName): iterable
    {
        yield from parent::configureFields($pageName);
        yield TextField::new('client', 'Client')->setColumns(6);
    }
}

The photographs, Publier and À la une come with it. Link the menu to the subclass (MenuItem::linkToCrud(Realisation::class, …)).

Taking Album over instead

To change Album itself for the whole site, declare App\Entity\Gallery\Album extends Base\Gallery\Entity\Album: omnibase maps App\Entity\Gallery over the bundle's entities (App wins), as it does for its own.

Propose a change docs/extending.md

Configuration reference

Dumped with Symfony's YamlReferenceDumper from Base\Gallery\DependencyInjection\GalleryConfiguration:

gallery:

    # The first segment of the gallery's addresses: /galerie, /galerie/{slug}. No slash.
    path:                 galerie

    # Albums listed per page.
    per_page:             24

    # The taxa that have a page of their own (/galerie/{taxon}): this class and its subclasses. With the default, omnibase's Taxon, a taxon has a page once it classifies a published album; with an application's own class, every taxon of that class has one.
    taxon_class:          Base\Entity\Thread\Taxon

    # The albums and the taxa pages in /sitemap.xml.
    sitemap:              true
Key Default
path galerie The first segment of the three routes. One or more URL segments, no slash around.
per_page 24
taxon_class Base\Entity\Thread\Taxon With the default, a taxon has a page once it classifies a published album. With an application's own class (App\Entity\Portfolio\Category), every taxon of that class has a page, empty or not - and the taxa of the other bundles (a shop's, a FAQ's) never do, nor are they offered in an album's screen of the back office. Listed by their position when the class has that field.
sitemap true

The parameters are flat: %gallery.path%, %gallery.per_page%, %gallery.taxon_class%, %gallery.sitemap%.

After changing path, clear the route pool: bin/console cache:pool:clear --all (omnisense/omnibase keeps the routes in a cache pool).

Services

Service Role
Base\Gallery\Repository\AlbumRepository, PhotoRepository the queries (Albums and photographs)
Base\Gallery\Service\JsonLd album($album, $url, $absolute), photo($photo, $absolute)
Base\Gallery\EventListener\SitemapListener the albums and the taxa pages of /sitemap.xml
Base\Gallery\Twig\GalleryExtension gallery_albums(), gallery_count(), gallery_taxa()
Base\Gallery\Form\PhotoType a photograph in the album's form

The admin screens are loaded when omnibase/admin is installed.

Translations

The domain gallery, in English, French and German (ICU format). A site overrides a message in its own translations/gallery+intl-icu.<locale>.yaml.

Propose a change docs/configuration.md

Testing

Running the suite

From the package's checkout, with its dev dependencies:

vendor/bin/phpunit

Inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/gallery/phpunit.xml.dist

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/gallery:ro \
    omnisense/omnibase-harness:latest test gallery

On this checkout, in the harness:

OK (30 tests, 85 assertions)

What the tests check

Test Checks
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the two screens
DependencyInjection\ConfigurationTest the defaults; a site's own path and taxon class; a path with slashes around is refused
Entity\AlbumTest a photograph joining an album takes the next place, once; the order shown; the cover marked, else the first; one cover at most; removing and renumbering; the photographs by label; publishing sets the state and the date once; featured and made at
Entity\PhotoTest the alt typed, else the caption, else the album's title; no album, an empty alt; the texts trimmed, empty ones null; the credit's sign once; a stored file's public address; cover, place and string form
Repository\NeighboursTest in the middle of the list; the first has no previous, the last no next; an album not listed has none
Service\JsonLdTest an album is an ImageGallery of its photographs in order; without photographs nor dates it keeps to what it knows; the paths left as they are without a host

Propose a change docs/testing.md